mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
2951 lines
125 KiB
Swift
2951 lines
125 KiB
Swift
import Foundation
|
||
import UIKit
|
||
import Display
|
||
import ComponentFlow
|
||
import SwiftSignalKit
|
||
import TelegramCore
|
||
import Postbox
|
||
import TelegramPresentationData
|
||
import PresentationDataUtils
|
||
import ViewControllerComponent
|
||
import AccountContext
|
||
import SolidRoundedButtonComponent
|
||
import MultilineTextComponent
|
||
import MultilineTextWithEntitiesComponent
|
||
import BundleIconComponent
|
||
import SolidRoundedButtonComponent
|
||
import BlurredBackgroundComponent
|
||
import Markdown
|
||
import InAppPurchaseManager
|
||
import ConfettiEffect
|
||
import TextFormat
|
||
import InstantPageCache
|
||
import UniversalMediaPlayer
|
||
import CheckNode
|
||
import AnimationCache
|
||
import MultiAnimationRenderer
|
||
|
||
public enum PremiumSource: Equatable {
|
||
public static func == (lhs: PremiumSource, rhs: PremiumSource) -> Bool {
|
||
switch lhs {
|
||
case .settings:
|
||
if case .settings = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .stickers:
|
||
if case .stickers = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .reactions:
|
||
if case .reactions = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .ads:
|
||
if case .ads = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .upload:
|
||
if case .upload = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .groupsAndChannels:
|
||
if case .groupsAndChannels = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .pinnedChats:
|
||
if case .pinnedChats = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .publicLinks:
|
||
if case .publicLinks = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .savedGifs:
|
||
if case .savedGifs = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .savedStickers:
|
||
if case .savedStickers = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .folders:
|
||
if case .folders = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .chatsPerFolder:
|
||
if case .chatsPerFolder = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .accounts:
|
||
if case .accounts = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .about:
|
||
if case .about = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .appIcons:
|
||
if case .appIcons = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .animatedEmoji:
|
||
if case .animatedEmoji = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case let .deeplink(link):
|
||
if case .deeplink(link) = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case let .profile(peerId):
|
||
if case .profile(peerId) = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case let .emojiStatus(lhsPeerId, lhsFileId, lhsFile, _):
|
||
if case let .emojiStatus(rhsPeerId, rhsFileId, rhsFile, _) = rhs {
|
||
return lhsPeerId == rhsPeerId && lhsFileId == rhsFileId && lhsFile == rhsFile
|
||
} else {
|
||
return false
|
||
}
|
||
case let .gift(from, to, duration):
|
||
if case .gift(from, to, duration) = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .giftTerms:
|
||
if case .giftTerms = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .voiceToText:
|
||
if case .voiceToText = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .fasterDownload:
|
||
if case .fasterDownload = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .translation:
|
||
if case .translation = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .linksPerSharedFolder:
|
||
if case .linksPerSharedFolder = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .membershipInSharedFolders:
|
||
if case .membershipInSharedFolders = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .stories:
|
||
if case .stories = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .storiesDownload:
|
||
if case .storiesDownload = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .storiesStealthMode:
|
||
if case .storiesStealthMode = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .storiesPermanentViews:
|
||
if case .storiesPermanentViews = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .storiesFormatting:
|
||
if case .storiesFormatting = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .storiesExpirationDurations:
|
||
if case .storiesExpirationDurations = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case .storiesSuggestedReactions:
|
||
if case .storiesSuggestedReactions = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
case let .channelBoost(peerId):
|
||
if case .channelBoost(peerId) = rhs {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
case settings
|
||
case stickers
|
||
case reactions
|
||
case ads
|
||
case upload
|
||
case groupsAndChannels
|
||
case pinnedChats
|
||
case publicLinks
|
||
case savedGifs
|
||
case savedStickers
|
||
case folders
|
||
case chatsPerFolder
|
||
case accounts
|
||
case about
|
||
case appIcons
|
||
case animatedEmoji
|
||
case deeplink(String?)
|
||
case profile(EnginePeer.Id)
|
||
case emojiStatus(EnginePeer.Id, Int64, TelegramMediaFile?, LoadedStickerPack?)
|
||
case gift(from: EnginePeer.Id, to: EnginePeer.Id, duration: Int32)
|
||
case giftTerms
|
||
case voiceToText
|
||
case fasterDownload
|
||
case translation
|
||
case linksPerSharedFolder
|
||
case membershipInSharedFolders
|
||
case stories
|
||
case storiesDownload
|
||
case storiesStealthMode
|
||
case storiesPermanentViews
|
||
case storiesFormatting
|
||
case storiesExpirationDurations
|
||
case storiesSuggestedReactions
|
||
case channelBoost(EnginePeer.Id)
|
||
|
||
var identifier: String? {
|
||
switch self {
|
||
case .settings:
|
||
return "settings"
|
||
case .stickers:
|
||
return "premium_stickers"
|
||
case .reactions:
|
||
return "infinite_reactions"
|
||
case .ads:
|
||
return "no_ads"
|
||
case .upload:
|
||
return "more_upload"
|
||
case .appIcons:
|
||
return "app_icons"
|
||
case .groupsAndChannels:
|
||
return "double_limits__channels"
|
||
case .pinnedChats:
|
||
return "double_limits__dialog_pinned"
|
||
case .publicLinks:
|
||
return "double_limits__channels_public"
|
||
case .savedGifs:
|
||
return "double_limits__saved_gifs"
|
||
case .savedStickers:
|
||
return "double_limits__stickers_faved"
|
||
case .folders:
|
||
return "double_limits__dialog_filters"
|
||
case .chatsPerFolder:
|
||
return "double_limits__dialog_filters_chats"
|
||
case .accounts:
|
||
return "double_limits__accounts"
|
||
case .about:
|
||
return "double_limits__about"
|
||
case .animatedEmoji:
|
||
return "animated_emoji"
|
||
case let .profile(id):
|
||
return "profile__\(id.id._internalGetInt64Value())"
|
||
case .emojiStatus:
|
||
return "emoji_status"
|
||
case .voiceToText:
|
||
return "voice_to_text"
|
||
case .fasterDownload:
|
||
return "faster_download"
|
||
case .gift, .giftTerms:
|
||
return nil
|
||
case let .deeplink(reference):
|
||
if let reference = reference {
|
||
return "deeplink_\(reference)"
|
||
} else {
|
||
return "deeplink"
|
||
}
|
||
case .translation:
|
||
return "translations"
|
||
case .linksPerSharedFolder:
|
||
return "double_limits__community_invites"
|
||
case .membershipInSharedFolders:
|
||
return "double_limits__communities_joined"
|
||
case .stories:
|
||
return "stories"
|
||
case .storiesDownload:
|
||
return "stories__save_stories_to_gallery"
|
||
case .storiesStealthMode:
|
||
return "stories__stealth_mode"
|
||
case .storiesPermanentViews:
|
||
return "stories__permanent_views_history"
|
||
case .storiesFormatting:
|
||
return "stories__links_and_formatting"
|
||
case .storiesExpirationDurations:
|
||
return "stories__expiration_durations"
|
||
case .storiesSuggestedReactions:
|
||
return "stories__suggested_reactions"
|
||
case let .channelBoost(peerId):
|
||
return "channel_boost__\(peerId.id._internalGetInt64Value())"
|
||
}
|
||
}
|
||
}
|
||
|
||
public enum PremiumPerk: CaseIterable {
|
||
case doubleLimits
|
||
case moreUpload
|
||
case fasterDownload
|
||
case voiceToText
|
||
case noAds
|
||
case uniqueReactions
|
||
case premiumStickers
|
||
case advancedChatManagement
|
||
case profileBadge
|
||
case animatedUserpics
|
||
case appIcons
|
||
case animatedEmoji
|
||
case emojiStatus
|
||
case translation
|
||
case stories
|
||
|
||
public static var allCases: [PremiumPerk] {
|
||
return [
|
||
.doubleLimits,
|
||
.moreUpload,
|
||
.fasterDownload,
|
||
.voiceToText,
|
||
.noAds,
|
||
.uniqueReactions,
|
||
.premiumStickers,
|
||
.advancedChatManagement,
|
||
.profileBadge,
|
||
.animatedUserpics,
|
||
.appIcons,
|
||
.animatedEmoji,
|
||
.emojiStatus,
|
||
.translation,
|
||
.stories
|
||
]
|
||
}
|
||
|
||
init?(identifier: String) {
|
||
for perk in PremiumPerk.allCases {
|
||
if perk.identifier == identifier {
|
||
self = perk
|
||
return
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var identifier: String {
|
||
switch self {
|
||
case .doubleLimits:
|
||
return "double_limits"
|
||
case .moreUpload:
|
||
return "more_upload"
|
||
case .fasterDownload:
|
||
return "faster_download"
|
||
case .voiceToText:
|
||
return "voice_to_text"
|
||
case .noAds:
|
||
return "no_ads"
|
||
case .uniqueReactions:
|
||
return "infinite_reactions"
|
||
case .premiumStickers:
|
||
return "premium_stickers"
|
||
case .advancedChatManagement:
|
||
return "advanced_chat_management"
|
||
case .profileBadge:
|
||
return "profile_badge"
|
||
case .animatedUserpics:
|
||
return "animated_userpics"
|
||
case .appIcons:
|
||
return "app_icons"
|
||
case .animatedEmoji:
|
||
return "animated_emoji"
|
||
case .emojiStatus:
|
||
return "emoji_status"
|
||
case .translation:
|
||
return "translations"
|
||
case .stories:
|
||
return "stories"
|
||
}
|
||
}
|
||
|
||
func title(strings: PresentationStrings) -> String {
|
||
switch self {
|
||
case .doubleLimits:
|
||
return strings.Premium_DoubledLimits
|
||
case .moreUpload:
|
||
return strings.Premium_UploadSize
|
||
case .fasterDownload:
|
||
return strings.Premium_FasterSpeed
|
||
case .voiceToText:
|
||
return strings.Premium_VoiceToText
|
||
case .noAds:
|
||
return strings.Premium_NoAds
|
||
case .uniqueReactions:
|
||
return strings.Premium_InfiniteReactions
|
||
case .premiumStickers:
|
||
return strings.Premium_Stickers
|
||
case .advancedChatManagement:
|
||
return strings.Premium_ChatManagement
|
||
case .profileBadge:
|
||
return strings.Premium_Badge
|
||
case .animatedUserpics:
|
||
return strings.Premium_Avatar
|
||
case .appIcons:
|
||
return strings.Premium_AppIcon
|
||
case .animatedEmoji:
|
||
return strings.Premium_AnimatedEmoji
|
||
case .emojiStatus:
|
||
return strings.Premium_EmojiStatus
|
||
case .translation:
|
||
return strings.Premium_Translation
|
||
case .stories:
|
||
return strings.Premium_Stories
|
||
}
|
||
}
|
||
|
||
func subtitle(strings: PresentationStrings) -> String {
|
||
switch self {
|
||
case .doubleLimits:
|
||
return strings.Premium_DoubledLimitsInfo
|
||
case .moreUpload:
|
||
return strings.Premium_UploadSizeInfo
|
||
case .fasterDownload:
|
||
return strings.Premium_FasterSpeedInfo
|
||
case .voiceToText:
|
||
return strings.Premium_VoiceToTextInfo
|
||
case .noAds:
|
||
return strings.Premium_NoAdsInfo
|
||
case .uniqueReactions:
|
||
return strings.Premium_InfiniteReactionsInfo
|
||
case .premiumStickers:
|
||
return strings.Premium_StickersInfo
|
||
case .advancedChatManagement:
|
||
return strings.Premium_ChatManagementInfo
|
||
case .profileBadge:
|
||
return strings.Premium_BadgeInfo
|
||
case .animatedUserpics:
|
||
return strings.Premium_AvatarInfo
|
||
case .appIcons:
|
||
return strings.Premium_AppIconInfo
|
||
case .animatedEmoji:
|
||
return strings.Premium_AnimatedEmojiInfo
|
||
case .emojiStatus:
|
||
return strings.Premium_EmojiStatusInfo
|
||
case .translation:
|
||
return strings.Premium_TranslationInfo
|
||
case .stories:
|
||
return strings.Premium_StoriesInfo
|
||
}
|
||
}
|
||
|
||
var iconName: String {
|
||
switch self {
|
||
case .doubleLimits:
|
||
return "Premium/Perk/Limits"
|
||
case .moreUpload:
|
||
return "Premium/Perk/Upload"
|
||
case .fasterDownload:
|
||
return "Premium/Perk/Speed"
|
||
case .voiceToText:
|
||
return "Premium/Perk/Voice"
|
||
case .noAds:
|
||
return "Premium/Perk/NoAds"
|
||
case .uniqueReactions:
|
||
return "Premium/Perk/Reactions"
|
||
case .premiumStickers:
|
||
return "Premium/Perk/Stickers"
|
||
case .advancedChatManagement:
|
||
return "Premium/Perk/Chat"
|
||
case .profileBadge:
|
||
return "Premium/Perk/Badge"
|
||
case .animatedUserpics:
|
||
return "Premium/Perk/Avatar"
|
||
case .appIcons:
|
||
return "Premium/Perk/AppIcon"
|
||
case .animatedEmoji:
|
||
return "Premium/Perk/Emoji"
|
||
case .emojiStatus:
|
||
return "Premium/Perk/Status"
|
||
case .translation:
|
||
return "Premium/Perk/Translation"
|
||
case .stories:
|
||
return "Premium/Perk/Stories"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct PremiumIntroConfiguration {
|
||
static var defaultValue: PremiumIntroConfiguration {
|
||
return PremiumIntroConfiguration(perks: [
|
||
.stories,
|
||
.doubleLimits,
|
||
.moreUpload,
|
||
.fasterDownload,
|
||
.translation,
|
||
.voiceToText,
|
||
.noAds,
|
||
.emojiStatus,
|
||
.uniqueReactions,
|
||
.premiumStickers,
|
||
.animatedEmoji,
|
||
.advancedChatManagement,
|
||
.profileBadge,
|
||
.animatedUserpics,
|
||
.appIcons
|
||
])
|
||
}
|
||
|
||
let perks: [PremiumPerk]
|
||
|
||
fileprivate init(perks: [PremiumPerk]) {
|
||
self.perks = perks
|
||
}
|
||
|
||
public static func with(appConfiguration: AppConfiguration) -> PremiumIntroConfiguration {
|
||
if let data = appConfiguration.data, let values = data["premium_promo_order"] as? [String] {
|
||
var perks: [PremiumPerk] = []
|
||
for value in values {
|
||
if let perk = PremiumPerk(identifier: value) {
|
||
if !perks.contains(perk) {
|
||
perks.append(perk)
|
||
} else {
|
||
perks = []
|
||
break
|
||
}
|
||
} else {
|
||
perks = []
|
||
break
|
||
}
|
||
}
|
||
if perks.count < 4 {
|
||
perks = PremiumIntroConfiguration.defaultValue.perks
|
||
}
|
||
return PremiumIntroConfiguration(perks: perks)
|
||
} else {
|
||
return .defaultValue
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct PremiumProduct: Equatable {
|
||
let option: PremiumPromoConfiguration.PremiumProductOption
|
||
let storeProduct: InAppPurchaseManager.Product
|
||
|
||
var id: String {
|
||
return self.storeProduct.id
|
||
}
|
||
|
||
var months: Int32 {
|
||
return self.option.months
|
||
}
|
||
|
||
var price: String {
|
||
return self.storeProduct.price
|
||
}
|
||
|
||
var pricePerMonth: String {
|
||
return self.storeProduct.pricePerMonth(Int(self.months))
|
||
}
|
||
|
||
var isCurrent: Bool {
|
||
return self.option.isCurrent
|
||
}
|
||
|
||
var transactionId: String? {
|
||
return self.option.transactionId
|
||
}
|
||
}
|
||
|
||
final class PremiumOptionComponent: CombinedComponent {
|
||
let title: String
|
||
let subtitle: String
|
||
let labelPrice: String
|
||
let discount: String
|
||
let selected: Bool
|
||
let primaryTextColor: UIColor
|
||
let secondaryTextColor: UIColor
|
||
let accentColor: UIColor
|
||
let checkForegroundColor: UIColor
|
||
let checkBorderColor: UIColor
|
||
|
||
init(
|
||
title: String,
|
||
subtitle: String,
|
||
labelPrice: String,
|
||
discount: String,
|
||
selected: Bool,
|
||
primaryTextColor: UIColor,
|
||
secondaryTextColor: UIColor,
|
||
accentColor: UIColor,
|
||
checkForegroundColor: UIColor,
|
||
checkBorderColor: UIColor
|
||
) {
|
||
self.title = title
|
||
self.subtitle = subtitle
|
||
self.labelPrice = labelPrice
|
||
self.discount = discount
|
||
self.selected = selected
|
||
self.primaryTextColor = primaryTextColor
|
||
self.secondaryTextColor = secondaryTextColor
|
||
self.accentColor = accentColor
|
||
self.checkForegroundColor = checkForegroundColor
|
||
self.checkBorderColor = checkBorderColor
|
||
}
|
||
|
||
static func ==(lhs: PremiumOptionComponent, rhs: PremiumOptionComponent) -> Bool {
|
||
if lhs.title != rhs.title {
|
||
return false
|
||
}
|
||
if lhs.subtitle != rhs.subtitle {
|
||
return false
|
||
}
|
||
if lhs.labelPrice != rhs.labelPrice {
|
||
return false
|
||
}
|
||
if lhs.discount != rhs.discount {
|
||
return false
|
||
}
|
||
if lhs.selected != rhs.selected {
|
||
return false
|
||
}
|
||
if lhs.primaryTextColor != rhs.primaryTextColor {
|
||
return false
|
||
}
|
||
if lhs.secondaryTextColor != rhs.secondaryTextColor {
|
||
return false
|
||
}
|
||
if lhs.accentColor != rhs.accentColor {
|
||
return false
|
||
}
|
||
if lhs.checkForegroundColor != rhs.checkForegroundColor {
|
||
return false
|
||
}
|
||
if lhs.checkBorderColor != rhs.checkBorderColor {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
static var body: Body {
|
||
let check = Child(CheckComponent.self)
|
||
let title = Child(MultilineTextComponent.self)
|
||
let subtitle = Child(MultilineTextComponent.self)
|
||
let discountBackground = Child(RoundedRectangle.self)
|
||
let discount = Child(MultilineTextComponent.self)
|
||
let label = Child(MultilineTextComponent.self)
|
||
|
||
return { context in
|
||
let component = context.component
|
||
|
||
var insets = UIEdgeInsets(top: 11.0, left: 46.0, bottom: 13.0, right: 16.0)
|
||
|
||
let label = label.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(
|
||
NSAttributedString(
|
||
string: component.labelPrice,
|
||
font: Font.regular(17),
|
||
textColor: component.secondaryTextColor
|
||
)
|
||
),
|
||
maximumNumberOfLines: 1
|
||
),
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
let title = title.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(
|
||
NSAttributedString(
|
||
string: component.title,
|
||
font: Font.regular(17),
|
||
textColor: component.primaryTextColor
|
||
)
|
||
),
|
||
maximumNumberOfLines: 1
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - insets.left - insets.right - label.size.width, height: context.availableSize.height),
|
||
transition: context.transition
|
||
)
|
||
|
||
var spacing: CGFloat = 0.0
|
||
var subtitleSize = CGSize()
|
||
if !component.subtitle.isEmpty {
|
||
spacing = 2.0
|
||
|
||
let subtitleFont = Font.regular(13)
|
||
let subtitleColor = component.secondaryTextColor
|
||
|
||
let subtitleString = parseMarkdownIntoAttributedString(
|
||
component.subtitle,
|
||
attributes: MarkdownAttributes(
|
||
body: MarkdownAttributeSet(font: subtitleFont, textColor: subtitleColor),
|
||
bold: MarkdownAttributeSet(font: subtitleFont, textColor: subtitleColor, additionalAttributes: [NSAttributedString.Key.strikethroughStyle.rawValue: NSUnderlineStyle.single.rawValue as NSNumber]),
|
||
link: MarkdownAttributeSet(font: subtitleFont, textColor: subtitleColor),
|
||
linkAttribute: { _ in return nil }
|
||
)
|
||
)
|
||
|
||
let subtitle = subtitle.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(subtitleString),
|
||
maximumNumberOfLines: 1
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - insets.left - insets.right, height: context.availableSize.height),
|
||
transition: context.transition
|
||
)
|
||
context.add(subtitle
|
||
.position(CGPoint(x: insets.left + subtitle.size.width / 2.0, y: insets.top + title.size.height + spacing + subtitle.size.height / 2.0))
|
||
)
|
||
subtitleSize = subtitle.size
|
||
|
||
insets.top -= 2.0
|
||
insets.bottom -= 2.0
|
||
}
|
||
|
||
let discountSize: CGSize
|
||
if !component.discount.isEmpty {
|
||
let discount = discount.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(
|
||
NSAttributedString(
|
||
string: component.discount,
|
||
font: Font.with(size: 14.0, design: .round, weight: .semibold, traits: []),
|
||
textColor: .white
|
||
)
|
||
),
|
||
maximumNumberOfLines: 1
|
||
),
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
discountSize = CGSize(width: discount.size.width + 6.0, height: 18.0)
|
||
|
||
let discountBackground = discountBackground.update(
|
||
component: RoundedRectangle(
|
||
color: component.accentColor,
|
||
cornerRadius: 5.0
|
||
),
|
||
availableSize: discountSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
context.add(discountBackground
|
||
.position(CGPoint(x: insets.left + title.size.width + 6.0 + discountSize.width / 2.0, y: insets.top + title.size.height / 2.0))
|
||
)
|
||
|
||
context.add(discount
|
||
.position(CGPoint(x: insets.left + title.size.width + 6.0 + discountSize.width / 2.0, y: insets.top + title.size.height / 2.0))
|
||
)
|
||
} else {
|
||
discountSize = CGSize(width: 0.0, height: 18.0)
|
||
}
|
||
|
||
let check = check.update(
|
||
component: CheckComponent(
|
||
theme: CheckComponent.Theme(
|
||
backgroundColor: component.accentColor,
|
||
strokeColor: component.checkForegroundColor,
|
||
borderColor: component.checkBorderColor,
|
||
overlayBorder: false,
|
||
hasInset: false,
|
||
hasShadow: false
|
||
),
|
||
selected: component.selected
|
||
),
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
context.add(title
|
||
.position(CGPoint(x: insets.left + title.size.width / 2.0, y: insets.top + title.size.height / 2.0))
|
||
)
|
||
|
||
let size = CGSize(width: context.availableSize.width, height: insets.top + title.size.height + spacing + subtitleSize.height + insets.bottom)
|
||
|
||
let distance = context.availableSize.width - insets.left - insets.right - label.size.width - subtitleSize.width
|
||
|
||
let labelY: CGFloat
|
||
if distance > 8.0 {
|
||
labelY = size.height / 2.0
|
||
} else {
|
||
labelY = insets.top + title.size.height / 2.0
|
||
}
|
||
|
||
context.add(label
|
||
.position(CGPoint(x: context.availableSize.width - insets.right - label.size.width / 2.0, y: labelY))
|
||
)
|
||
|
||
context.add(check
|
||
.position(CGPoint(x: 4.0 + check.size.width / 2.0, y: size.height / 2.0))
|
||
)
|
||
|
||
return size
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class CheckComponent: Component {
|
||
struct Theme: Equatable {
|
||
public let backgroundColor: UIColor
|
||
public let strokeColor: UIColor
|
||
public let borderColor: UIColor
|
||
public let overlayBorder: Bool
|
||
public let hasInset: Bool
|
||
public let hasShadow: Bool
|
||
public let filledBorder: Bool
|
||
public let borderWidth: CGFloat?
|
||
|
||
public init(backgroundColor: UIColor, strokeColor: UIColor, borderColor: UIColor, overlayBorder: Bool, hasInset: Bool, hasShadow: Bool, filledBorder: Bool = false, borderWidth: CGFloat? = nil) {
|
||
self.backgroundColor = backgroundColor
|
||
self.strokeColor = strokeColor
|
||
self.borderColor = borderColor
|
||
self.overlayBorder = overlayBorder
|
||
self.hasInset = hasInset
|
||
self.hasShadow = hasShadow
|
||
self.filledBorder = filledBorder
|
||
self.borderWidth = borderWidth
|
||
}
|
||
|
||
var checkNodeTheme: CheckNodeTheme {
|
||
return CheckNodeTheme(
|
||
backgroundColor: self.backgroundColor,
|
||
strokeColor: self.strokeColor,
|
||
borderColor: self.borderColor,
|
||
overlayBorder: self.overlayBorder,
|
||
hasInset: self.hasInset,
|
||
hasShadow: self.hasShadow,
|
||
filledBorder: self.filledBorder,
|
||
borderWidth: self.borderWidth
|
||
)
|
||
}
|
||
}
|
||
|
||
let theme: Theme
|
||
let selected: Bool
|
||
|
||
init(
|
||
theme: Theme,
|
||
selected: Bool
|
||
) {
|
||
self.theme = theme
|
||
self.selected = selected
|
||
}
|
||
|
||
static func ==(lhs: CheckComponent, rhs: CheckComponent) -> Bool {
|
||
if lhs.theme != rhs.theme {
|
||
return false
|
||
}
|
||
if lhs.selected != rhs.selected {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
final class View: UIView {
|
||
private var currentValue: CGFloat?
|
||
private var animator: DisplayLinkAnimator?
|
||
|
||
private var checkLayer: CheckLayer {
|
||
return self.layer as! CheckLayer
|
||
}
|
||
|
||
override class var layerClass: AnyClass {
|
||
return CheckLayer.self
|
||
}
|
||
|
||
init() {
|
||
super.init(frame: CGRect())
|
||
}
|
||
|
||
required init?(coder aDecoder: NSCoder) {
|
||
preconditionFailure()
|
||
}
|
||
|
||
|
||
func update(component: CheckComponent, availableSize: CGSize, transition: Transition) -> CGSize {
|
||
self.checkLayer.setSelected(component.selected, animated: true)
|
||
self.checkLayer.theme = component.theme.checkNodeTheme
|
||
|
||
return CGSize(width: 22.0, height: 22.0)
|
||
}
|
||
}
|
||
|
||
func makeView() -> View {
|
||
return View()
|
||
}
|
||
|
||
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||
return view.update(component: self, availableSize: availableSize, transition: transition)
|
||
}
|
||
}
|
||
|
||
final class SectionGroupComponent: Component {
|
||
public final class Item: Equatable {
|
||
public let content: AnyComponentWithIdentity<Empty>
|
||
public let accessibilityLabel: String
|
||
public let isEnabled: Bool
|
||
public let action: () -> Void
|
||
|
||
public init(_ content: AnyComponentWithIdentity<Empty>, accessibilityLabel: String, isEnabled: Bool = true, action: @escaping () -> Void) {
|
||
self.content = content
|
||
self.accessibilityLabel = accessibilityLabel
|
||
self.isEnabled = isEnabled
|
||
self.action = action
|
||
}
|
||
|
||
public static func ==(lhs: Item, rhs: Item) -> Bool {
|
||
if lhs.content != rhs.content {
|
||
return false
|
||
}
|
||
if lhs.accessibilityLabel != rhs.accessibilityLabel {
|
||
return false
|
||
}
|
||
if lhs.isEnabled != rhs.isEnabled {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
public let items: [Item]
|
||
public let backgroundColor: UIColor
|
||
public let selectionColor: UIColor
|
||
public let separatorColor: UIColor
|
||
|
||
public init(
|
||
items: [Item],
|
||
backgroundColor: UIColor,
|
||
selectionColor: UIColor,
|
||
separatorColor: UIColor
|
||
) {
|
||
self.items = items
|
||
self.backgroundColor = backgroundColor
|
||
self.selectionColor = selectionColor
|
||
self.separatorColor = separatorColor
|
||
}
|
||
|
||
public static func ==(lhs: SectionGroupComponent, rhs: SectionGroupComponent) -> Bool {
|
||
if lhs.items != rhs.items {
|
||
return false
|
||
}
|
||
if lhs.backgroundColor != rhs.backgroundColor {
|
||
return false
|
||
}
|
||
if lhs.selectionColor != rhs.selectionColor {
|
||
return false
|
||
}
|
||
if lhs.separatorColor != rhs.separatorColor {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
public final class View: UIView {
|
||
private var buttonViews: [AnyHashable: HighlightTrackingButton] = [:]
|
||
private var itemViews: [AnyHashable: ComponentHostView<Empty>] = [:]
|
||
private var separatorViews: [UIView] = []
|
||
|
||
private var component: SectionGroupComponent?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
@objc private func buttonPressed(_ sender: HighlightTrackingButton) {
|
||
guard let component = self.component else {
|
||
return
|
||
}
|
||
|
||
if let (id, _) = self.buttonViews.first(where: { $0.value === sender }), let item = component.items.first(where: { $0.content.id == id }) {
|
||
item.action()
|
||
}
|
||
}
|
||
|
||
func update(component: SectionGroupComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||
let sideInset: CGFloat = 16.0
|
||
|
||
self.backgroundColor = component.backgroundColor
|
||
|
||
var size = CGSize(width: availableSize.width, height: 0.0)
|
||
|
||
var validIds: [AnyHashable] = []
|
||
|
||
var i = 0
|
||
for item in component.items {
|
||
validIds.append(item.content.id)
|
||
|
||
let buttonView: HighlightTrackingButton
|
||
let itemView: ComponentHostView<Empty>
|
||
var itemTransition = transition
|
||
|
||
if let current = self.buttonViews[item.content.id] {
|
||
buttonView = current
|
||
} else {
|
||
buttonView = HighlightTrackingButton()
|
||
buttonView.isMultipleTouchEnabled = false
|
||
buttonView.isExclusiveTouch = true
|
||
buttonView.addTarget(self, action: #selector(self.buttonPressed(_:)), for: .touchUpInside)
|
||
self.buttonViews[item.content.id] = buttonView
|
||
self.addSubview(buttonView)
|
||
}
|
||
buttonView.accessibilityLabel = item.accessibilityLabel
|
||
|
||
if let current = self.itemViews[item.content.id] {
|
||
itemView = current
|
||
} else {
|
||
itemTransition = transition.withAnimation(.none)
|
||
itemView = ComponentHostView<Empty>()
|
||
self.itemViews[item.content.id] = itemView
|
||
self.addSubview(itemView)
|
||
}
|
||
let itemSize = itemView.update(
|
||
transition: itemTransition,
|
||
component: item.content.component,
|
||
environment: {},
|
||
containerSize: CGSize(width: size.width - sideInset, height: .greatestFiniteMagnitude)
|
||
)
|
||
buttonView.isEnabled = item.isEnabled
|
||
itemView.alpha = item.isEnabled ? 1.0 : 0.3
|
||
|
||
let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height), size: itemSize)
|
||
buttonView.frame = CGRect(origin: itemFrame.origin, size: CGSize(width: availableSize.width, height: itemSize.height + UIScreenPixel))
|
||
itemView.frame = CGRect(origin: CGPoint(x: itemFrame.minX + sideInset, y: itemFrame.minY + floor((itemFrame.height - itemSize.height) / 2.0)), size: itemSize)
|
||
itemView.isUserInteractionEnabled = false
|
||
|
||
buttonView.highligthedChanged = { [weak buttonView] highlighted in
|
||
if highlighted {
|
||
buttonView?.backgroundColor = component.selectionColor
|
||
} else {
|
||
UIView.animate(withDuration: 0.3, animations: {
|
||
buttonView?.backgroundColor = nil
|
||
})
|
||
}
|
||
}
|
||
|
||
size.height += itemSize.height
|
||
|
||
if i != component.items.count - 1 {
|
||
let separatorView: UIView
|
||
if self.separatorViews.count > i {
|
||
separatorView = self.separatorViews[i]
|
||
} else {
|
||
separatorView = UIView()
|
||
self.separatorViews.append(separatorView)
|
||
self.addSubview(separatorView)
|
||
}
|
||
separatorView.backgroundColor = component.separatorColor
|
||
|
||
separatorView.frame = CGRect(origin: CGPoint(x: itemFrame.minX + sideInset * 2.0 + 30.0, y: itemFrame.maxY), size: CGSize(width: size.width - sideInset * 2.0 - 30.0, height: UIScreenPixel))
|
||
}
|
||
i += 1
|
||
}
|
||
|
||
var removeIds: [AnyHashable] = []
|
||
for (id, itemView) in self.itemViews {
|
||
if !validIds.contains(id) {
|
||
removeIds.append(id)
|
||
itemView.removeFromSuperview()
|
||
}
|
||
}
|
||
for id in removeIds {
|
||
self.itemViews.removeValue(forKey: id)
|
||
}
|
||
|
||
if !self.separatorViews.isEmpty, self.separatorViews.count > component.items.count - 1 {
|
||
for i in (component.items.count - 1) ..< self.separatorViews.count {
|
||
self.separatorViews[i].removeFromSuperview()
|
||
}
|
||
self.separatorViews.removeSubrange((component.items.count - 1) ..< self.separatorViews.count)
|
||
}
|
||
|
||
self.component = component
|
||
|
||
return size
|
||
}
|
||
}
|
||
|
||
public func makeView() -> View {
|
||
return View(frame: CGRect())
|
||
}
|
||
|
||
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||
}
|
||
}
|
||
|
||
final class PerkComponent: CombinedComponent {
|
||
let iconName: String
|
||
let iconBackgroundColors: [UIColor]
|
||
let title: String
|
||
let titleColor: UIColor
|
||
let subtitle: String
|
||
let subtitleColor: UIColor
|
||
let arrowColor: UIColor
|
||
let accentColor: UIColor
|
||
let badge: String?
|
||
|
||
init(
|
||
iconName: String,
|
||
iconBackgroundColors: [UIColor],
|
||
title: String,
|
||
titleColor: UIColor,
|
||
subtitle: String,
|
||
subtitleColor: UIColor,
|
||
arrowColor: UIColor,
|
||
accentColor: UIColor,
|
||
badge: String? = nil
|
||
) {
|
||
self.iconName = iconName
|
||
self.iconBackgroundColors = iconBackgroundColors
|
||
self.title = title
|
||
self.titleColor = titleColor
|
||
self.subtitle = subtitle
|
||
self.subtitleColor = subtitleColor
|
||
self.arrowColor = arrowColor
|
||
self.accentColor = accentColor
|
||
self.badge = badge
|
||
}
|
||
|
||
static func ==(lhs: PerkComponent, rhs: PerkComponent) -> Bool {
|
||
if lhs.iconName != rhs.iconName {
|
||
return false
|
||
}
|
||
if lhs.iconBackgroundColors != rhs.iconBackgroundColors {
|
||
return false
|
||
}
|
||
if lhs.title != rhs.title {
|
||
return false
|
||
}
|
||
if lhs.titleColor != rhs.titleColor {
|
||
return false
|
||
}
|
||
if lhs.subtitle != rhs.subtitle {
|
||
return false
|
||
}
|
||
if lhs.subtitleColor != rhs.subtitleColor {
|
||
return false
|
||
}
|
||
if lhs.arrowColor != rhs.arrowColor {
|
||
return false
|
||
}
|
||
if lhs.accentColor != rhs.accentColor {
|
||
return false
|
||
}
|
||
if lhs.badge != rhs.badge {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
static var body: Body {
|
||
let iconBackground = Child(RoundedRectangle.self)
|
||
let icon = Child(BundleIconComponent.self)
|
||
let title = Child(MultilineTextComponent.self)
|
||
let subtitle = Child(MultilineTextComponent.self)
|
||
let arrow = Child(BundleIconComponent.self)
|
||
let badgeBackground = Child(RoundedRectangle.self)
|
||
let badgeText = Child(MultilineTextComponent.self)
|
||
|
||
return { context in
|
||
let component = context.component
|
||
|
||
let sideInset: CGFloat = 16.0
|
||
let iconTopInset: CGFloat = 15.0
|
||
let textTopInset: CGFloat = 9.0
|
||
let textBottomInset: CGFloat = 9.0
|
||
let spacing: CGFloat = 2.0
|
||
let iconSize = CGSize(width: 30.0, height: 30.0)
|
||
|
||
let iconBackground = iconBackground.update(
|
||
component: RoundedRectangle(
|
||
colors: component.iconBackgroundColors,
|
||
cornerRadius: 7.0,
|
||
gradientDirection: .vertical),
|
||
availableSize: iconSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
let icon = icon.update(
|
||
component: BundleIconComponent(
|
||
name: component.iconName,
|
||
tintColor: .white
|
||
),
|
||
availableSize: iconSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
let arrow = arrow.update(
|
||
component: BundleIconComponent(
|
||
name: "Item List/DisclosureArrow",
|
||
tintColor: component.arrowColor
|
||
),
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
let title = title.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(
|
||
NSAttributedString(
|
||
string: component.title,
|
||
font: Font.regular(17),
|
||
textColor: component.titleColor
|
||
)
|
||
),
|
||
maximumNumberOfLines: 0,
|
||
lineSpacing: 0.1
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - iconBackground.size.width - sideInset * 2.83, height: context.availableSize.height),
|
||
transition: context.transition
|
||
)
|
||
|
||
let subtitle = subtitle.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(
|
||
NSAttributedString(
|
||
string: component.subtitle,
|
||
font: Font.regular(13),
|
||
textColor: component.subtitleColor
|
||
)
|
||
),
|
||
maximumNumberOfLines: 0,
|
||
lineSpacing: 0.1
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - iconBackground.size.width - sideInset * 2.83, height: context.availableSize.height),
|
||
transition: context.transition
|
||
)
|
||
|
||
let iconPosition = CGPoint(x: iconBackground.size.width / 2.0, y: iconTopInset + iconBackground.size.height / 2.0)
|
||
context.add(iconBackground
|
||
.position(iconPosition)
|
||
)
|
||
|
||
context.add(icon
|
||
.position(iconPosition)
|
||
)
|
||
|
||
context.add(title
|
||
.position(CGPoint(x: iconBackground.size.width + sideInset + title.size.width / 2.0, y: textTopInset + title.size.height / 2.0))
|
||
)
|
||
|
||
if let badge = component.badge {
|
||
let badgeText = badgeText.update(
|
||
component: MultilineTextComponent(text: .plain(NSAttributedString(string: badge, font: Font.semibold(11.0), textColor: .white))),
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
let badgeWidth = badgeText.size.width + 7.0
|
||
let badgeBackground = badgeBackground.update(
|
||
component: RoundedRectangle(
|
||
colors: [component.accentColor],
|
||
cornerRadius: 5.0,
|
||
gradientDirection: .vertical),
|
||
availableSize: CGSize(width: badgeWidth, height: 16.0),
|
||
transition: context.transition
|
||
)
|
||
|
||
context.add(badgeBackground
|
||
.position(CGPoint(x: iconBackground.size.width + sideInset + title.size.width + badgeWidth / 2.0 + 8.0, y: textTopInset + title.size.height / 2.0 - 1.0))
|
||
)
|
||
|
||
context.add(badgeText
|
||
.position(CGPoint(x: iconBackground.size.width + sideInset + title.size.width + badgeWidth / 2.0 + 8.0, y: textTopInset + title.size.height / 2.0 - 1.0))
|
||
)
|
||
}
|
||
|
||
context.add(subtitle
|
||
.position(CGPoint(x: iconBackground.size.width + sideInset + subtitle.size.width / 2.0, y: textTopInset + title.size.height + spacing + subtitle.size.height / 2.0))
|
||
)
|
||
|
||
let size = CGSize(width: context.availableSize.width, height: textTopInset + title.size.height + spacing + subtitle.size.height + textBottomInset)
|
||
context.add(arrow
|
||
.position(CGPoint(x: context.availableSize.width - 7.0 - arrow.size.width / 2.0, y: size.height / 2.0))
|
||
)
|
||
|
||
return size
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
private final class PremiumIntroScreenContentComponent: CombinedComponent {
|
||
typealias EnvironmentType = (ViewControllerComponentContainer.Environment, ScrollChildEnvironment)
|
||
|
||
let context: AccountContext
|
||
let source: PremiumSource
|
||
let forceDark: Bool
|
||
let isPremium: Bool?
|
||
let justBought: Bool
|
||
let otherPeerName: String?
|
||
let products: [PremiumProduct]?
|
||
let selectedProductId: String?
|
||
let validPurchases: [InAppPurchaseManager.ReceiptPurchase]
|
||
let promoConfiguration: PremiumPromoConfiguration?
|
||
let present: (ViewController) -> Void
|
||
let selectProduct: (String) -> Void
|
||
let buy: () -> Void
|
||
let updateIsFocused: (Bool) -> Void
|
||
|
||
init(
|
||
context: AccountContext,
|
||
source: PremiumSource,
|
||
forceDark: Bool,
|
||
isPremium: Bool?,
|
||
justBought: Bool,
|
||
otherPeerName: String?,
|
||
products: [PremiumProduct]?,
|
||
selectedProductId: String?,
|
||
validPurchases: [InAppPurchaseManager.ReceiptPurchase],
|
||
promoConfiguration: PremiumPromoConfiguration?,
|
||
present: @escaping (ViewController) -> Void,
|
||
selectProduct: @escaping (String) -> Void,
|
||
buy: @escaping () -> Void,
|
||
updateIsFocused: @escaping (Bool) -> Void
|
||
) {
|
||
self.context = context
|
||
self.source = source
|
||
self.forceDark = forceDark
|
||
self.isPremium = isPremium
|
||
self.justBought = justBought
|
||
self.otherPeerName = otherPeerName
|
||
self.products = products
|
||
self.selectedProductId = selectedProductId
|
||
self.validPurchases = validPurchases
|
||
self.promoConfiguration = promoConfiguration
|
||
self.present = present
|
||
self.selectProduct = selectProduct
|
||
self.buy = buy
|
||
self.updateIsFocused = updateIsFocused
|
||
}
|
||
|
||
static func ==(lhs: PremiumIntroScreenContentComponent, rhs: PremiumIntroScreenContentComponent) -> Bool {
|
||
if lhs.context !== rhs.context {
|
||
return false
|
||
}
|
||
if lhs.source != rhs.source {
|
||
return false
|
||
}
|
||
if lhs.isPremium != rhs.isPremium {
|
||
return false
|
||
}
|
||
if lhs.forceDark != rhs.forceDark {
|
||
return false
|
||
}
|
||
if lhs.justBought != rhs.justBought {
|
||
return false
|
||
}
|
||
if lhs.otherPeerName != rhs.otherPeerName {
|
||
return false
|
||
}
|
||
if lhs.products != rhs.products {
|
||
return false
|
||
}
|
||
if lhs.selectedProductId != rhs.selectedProductId {
|
||
return false
|
||
}
|
||
if lhs.validPurchases != rhs.validPurchases {
|
||
return false
|
||
}
|
||
if lhs.promoConfiguration != rhs.promoConfiguration {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
final class State: ComponentState {
|
||
private let context: AccountContext
|
||
|
||
var products: [PremiumProduct]?
|
||
var selectedProductId: String?
|
||
var validPurchases: [InAppPurchaseManager.ReceiptPurchase] = []
|
||
|
||
var isPremium: Bool?
|
||
|
||
private var disposable: Disposable?
|
||
private(set) var configuration = PremiumIntroConfiguration.defaultValue
|
||
|
||
private var stickersDisposable: Disposable?
|
||
private var preloadDisposableSet = DisposableSet()
|
||
|
||
var price: String? {
|
||
return self.products?.first(where: { $0.id == self.selectedProductId })?.price
|
||
}
|
||
|
||
var isAnnual: Bool {
|
||
return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".annual") ?? false
|
||
}
|
||
|
||
var canUpgrade: Bool {
|
||
if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId {
|
||
if self.validPurchases.contains(where: { $0.transactionId == transactionId }) {
|
||
return products.first(where: { $0.months > current.months }) != nil
|
||
} else {
|
||
return false
|
||
}
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
|
||
init(context: AccountContext, source: PremiumSource) {
|
||
self.context = context
|
||
|
||
super.init()
|
||
|
||
self.disposable = (context.engine.data.subscribe(
|
||
TelegramEngine.EngineData.Item.Configuration.App()
|
||
)
|
||
|> deliverOnMainQueue).start(next: { [weak self] appConfiguration in
|
||
if let strongSelf = self {
|
||
strongSelf.configuration = PremiumIntroConfiguration.with(appConfiguration: appConfiguration)
|
||
strongSelf.updated(transition: .immediate)
|
||
|
||
if let identifier = source.identifier {
|
||
var jsonString: String = "{"
|
||
jsonString += "\"source\": \"\(identifier)\","
|
||
|
||
jsonString += "\"data\": {\"premium_promo_order\":["
|
||
var isFirst = true
|
||
for perk in strongSelf.configuration.perks {
|
||
if !isFirst {
|
||
jsonString += ","
|
||
}
|
||
isFirst = false
|
||
jsonString += "\"\(perk.identifier)\""
|
||
}
|
||
jsonString += "]}}"
|
||
|
||
if let data = jsonString.data(using: .utf8), let json = JSON(data: data) {
|
||
addAppLogEvent(postbox: strongSelf.context.account.postbox, type: "premium.promo_screen_show", data: json)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
let _ = updatePremiumPromoConfigurationOnce(account: context.account).start()
|
||
|
||
let stickersKey: PostboxViewKey = .orderedItemList(id: Namespaces.OrderedItemList.CloudPremiumStickers)
|
||
self.stickersDisposable = (self.context.account.postbox.combinedView(keys: [stickersKey])
|
||
|> deliverOnMainQueue).start(next: { [weak self] views in
|
||
guard let strongSelf = self else {
|
||
return
|
||
}
|
||
if let view = views.views[stickersKey] as? OrderedItemListView {
|
||
for item in view.items {
|
||
if let mediaItem = item.contents.get(RecentMediaItem.self) {
|
||
let file = mediaItem.media
|
||
strongSelf.preloadDisposableSet.add(freeMediaFileResourceInteractiveFetched(account: context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start())
|
||
if let effect = file.videoThumbnails.first {
|
||
strongSelf.preloadDisposableSet.add(freeMediaFileResourceInteractiveFetched(account: context.account, userLocation: .other, fileReference: .standalone(media: file), resource: effect.resource).start())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
deinit {
|
||
self.disposable?.dispose()
|
||
self.preloadDisposableSet.dispose()
|
||
self.stickersDisposable?.dispose()
|
||
}
|
||
}
|
||
|
||
func makeState() -> State {
|
||
return State(context: self.context, source: self.source)
|
||
}
|
||
|
||
static var body: Body {
|
||
let overscroll = Child(Rectangle.self)
|
||
let fade = Child(RoundedRectangle.self)
|
||
let text = Child(MultilineTextComponent.self)
|
||
let optionsSection = Child(SectionGroupComponent.self)
|
||
let perksSection = Child(SectionGroupComponent.self)
|
||
let infoBackground = Child(RoundedRectangle.self)
|
||
let infoTitle = Child(MultilineTextComponent.self)
|
||
let infoText = Child(MultilineTextComponent.self)
|
||
let termsText = Child(MultilineTextComponent.self)
|
||
|
||
return { context in
|
||
let sideInset: CGFloat = 16.0
|
||
|
||
let scrollEnvironment = context.environment[ScrollChildEnvironment.self].value
|
||
let environment = context.environment[ViewControllerComponentContainer.Environment.self].value
|
||
let state = context.state
|
||
state.products = context.component.products
|
||
state.selectedProductId = context.component.selectedProductId
|
||
state.validPurchases = context.component.validPurchases
|
||
state.isPremium = context.component.isPremium
|
||
|
||
let theme = environment.theme
|
||
let strings = environment.strings
|
||
|
||
let availableWidth = context.availableSize.width
|
||
let sideInsets = sideInset * 2.0 + environment.safeInsets.left + environment.safeInsets.right
|
||
var size = CGSize(width: context.availableSize.width, height: 0.0)
|
||
|
||
var topBackgroundColor = theme.list.plainBackgroundColor
|
||
let bottomBackgroundColor = theme.list.blocksBackgroundColor
|
||
if theme.overallDarkAppearance {
|
||
topBackgroundColor = bottomBackgroundColor
|
||
}
|
||
|
||
let overscroll = overscroll.update(
|
||
component: Rectangle(color: topBackgroundColor),
|
||
availableSize: CGSize(width: context.availableSize.width, height: 1000),
|
||
transition: context.transition
|
||
)
|
||
context.add(overscroll
|
||
.position(CGPoint(x: overscroll.size.width / 2.0, y: -overscroll.size.height / 2.0))
|
||
)
|
||
|
||
let fade = fade.update(
|
||
component: RoundedRectangle(
|
||
colors: [
|
||
topBackgroundColor,
|
||
bottomBackgroundColor
|
||
],
|
||
cornerRadius: 0.0,
|
||
gradientDirection: .vertical
|
||
),
|
||
availableSize: CGSize(width: availableWidth, height: 300),
|
||
transition: context.transition
|
||
)
|
||
context.add(fade
|
||
.position(CGPoint(x: fade.size.width / 2.0, y: fade.size.height / 2.0))
|
||
)
|
||
|
||
size.height += 183.0 + 10.0 + environment.navigationHeight - 56.0
|
||
|
||
let textColor = theme.list.itemPrimaryTextColor
|
||
let accentColor = theme.list.itemAccentColor
|
||
let titleColor = theme.list.itemPrimaryTextColor
|
||
let subtitleColor = theme.list.itemSecondaryTextColor
|
||
let arrowColor = theme.list.disclosureArrowColor
|
||
|
||
let textFont = Font.regular(15.0)
|
||
let boldTextFont = Font.semibold(15.0)
|
||
|
||
let textString: String
|
||
if case .emojiStatus = context.component.source {
|
||
textString = strings.Premium_EmojiStatusText.replacingOccurrences(of: "#", with: "# ")
|
||
} else if case .giftTerms = context.component.source {
|
||
textString = strings.Premium_PersonalDescription
|
||
} else if let _ = context.component.otherPeerName {
|
||
if case let .gift(fromId, _, _) = context.component.source {
|
||
if fromId == context.component.context.account.peerId {
|
||
textString = strings.Premium_GiftedDescriptionYou
|
||
} else {
|
||
textString = strings.Premium_GiftedDescription
|
||
}
|
||
} else {
|
||
textString = strings.Premium_PersonalDescription
|
||
}
|
||
} else if context.component.isPremium == true {
|
||
if !context.component.justBought, let products = state.products, let current = products.first(where: { $0.isCurrent }), current.months == 1 {
|
||
textString = strings.Premium_UpgradeDescription
|
||
} else {
|
||
textString = strings.Premium_SubscribedDescription
|
||
}
|
||
} else {
|
||
textString = strings.Premium_Description
|
||
}
|
||
|
||
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: accentColor), linkAttribute: { _ in
|
||
return nil
|
||
})
|
||
|
||
let text = text.update(
|
||
component: MultilineTextComponent(
|
||
text: .markdown(
|
||
text: textString,
|
||
attributes: markdownAttributes
|
||
),
|
||
horizontalAlignment: .center,
|
||
maximumNumberOfLines: 0,
|
||
lineSpacing: 0.2
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: availableWidth - sideInsets - 8.0, height: 240.0),
|
||
transition: .immediate
|
||
)
|
||
context.add(text
|
||
.position(CGPoint(x: size.width / 2.0, y: size.height + text.size.height / 2.0))
|
||
)
|
||
size.height += text.size.height
|
||
size.height += 21.0
|
||
|
||
let gradientColors: [UIColor] = [
|
||
UIColor(rgb: 0xF27C30),
|
||
UIColor(rgb: 0xE36850),
|
||
UIColor(rgb: 0xda5d63),
|
||
UIColor(rgb: 0xD15078),
|
||
UIColor(rgb: 0xC14998),
|
||
UIColor(rgb: 0xB24CB5),
|
||
UIColor(rgb: 0xA34ED0),
|
||
UIColor(rgb: 0x9054E9),
|
||
UIColor(rgb: 0x7561EB),
|
||
UIColor(rgb: 0x5A6EEE),
|
||
UIColor(rgb: 0x548DFF),
|
||
UIColor(rgb: 0x54A3FF),
|
||
UIColor(rgb: 0x54bdff),
|
||
UIColor(rgb: 0x71c8ff),
|
||
UIColor(rgb: 0xa0daff)
|
||
]
|
||
|
||
let accountContext = context.component.context
|
||
let present = context.component.present
|
||
let selectProduct = context.component.selectProduct
|
||
let buy = context.component.buy
|
||
let updateIsFocused = context.component.updateIsFocused
|
||
|
||
let layoutOptions = {
|
||
if let products = state.products, products.count > 1, state.isPremium == false || (!context.component.justBought && state.canUpgrade) {
|
||
var optionsItems: [SectionGroupComponent.Item] = []
|
||
let gradientColors: [UIColor] = [
|
||
UIColor(rgb: 0x8e77ff),
|
||
UIColor(rgb: 0x9a6fff),
|
||
UIColor(rgb: 0xb36eee)
|
||
]
|
||
|
||
let shortestOptionPrice: (Int64, NSDecimalNumber)
|
||
if let product = products.first(where: { $0.id.hasSuffix(".monthly") }) {
|
||
shortestOptionPrice = (Int64(Float(product.storeProduct.priceCurrencyAndAmount.amount)), product.storeProduct.priceValue)
|
||
} else {
|
||
shortestOptionPrice = (1, NSDecimalNumber(decimal: 1))
|
||
}
|
||
|
||
let currentProductMonths = state.products?.first(where: { $0.isCurrent })?.months ?? 0
|
||
|
||
var i = 0
|
||
for product in products {
|
||
let giftTitle: String
|
||
if product.id.hasSuffix(".monthly") {
|
||
giftTitle = strings.Premium_Monthly
|
||
} else if product.id.hasSuffix(".semiannual") {
|
||
giftTitle = strings.Premium_Semiannual
|
||
} else {
|
||
giftTitle = strings.Premium_Annual
|
||
}
|
||
|
||
let fraction = Float(product.storeProduct.priceCurrencyAndAmount.amount) / Float(product.months) / Float(shortestOptionPrice.0)
|
||
let discountValue = Int(round((1.0 - fraction) * 20.0) * 5.0)
|
||
let discount: String
|
||
if discountValue > 0 {
|
||
discount = "-\(discountValue)%"
|
||
} else {
|
||
discount = ""
|
||
}
|
||
|
||
let defaultPrice = product.storeProduct.defaultPrice(shortestOptionPrice.1, monthsCount: Int(product.months))
|
||
|
||
var subtitle = ""
|
||
var accessibilitySubtitle = ""
|
||
var pricePerMonth = product.price
|
||
if product.months > 1 {
|
||
pricePerMonth = product.storeProduct.pricePerMonth(Int(product.months))
|
||
|
||
if discountValue > 0 {
|
||
subtitle = "**\(defaultPrice)** \(product.price)"
|
||
accessibilitySubtitle = product.price
|
||
if product.months == 12 {
|
||
subtitle = environment.strings.Premium_PricePerYear(subtitle).string
|
||
accessibilitySubtitle = environment.strings.Premium_PricePerYear(accessibilitySubtitle).string
|
||
}
|
||
} else {
|
||
subtitle = product.price
|
||
}
|
||
}
|
||
if product.isCurrent {
|
||
subtitle = environment.strings.Premium_CurrentPlan
|
||
accessibilitySubtitle = subtitle
|
||
}
|
||
pricePerMonth = environment.strings.Premium_PricePerMonth(pricePerMonth).string
|
||
|
||
optionsItems.append(
|
||
SectionGroupComponent.Item(
|
||
AnyComponentWithIdentity(
|
||
id: product.id,
|
||
component: AnyComponent(
|
||
PremiumOptionComponent(
|
||
title: giftTitle,
|
||
subtitle: subtitle,
|
||
labelPrice: pricePerMonth,
|
||
discount: discount,
|
||
selected: !product.isCurrent && product.id == state.selectedProductId,
|
||
primaryTextColor: textColor,
|
||
secondaryTextColor: subtitleColor,
|
||
accentColor: gradientColors[i],
|
||
checkForegroundColor: environment.theme.list.itemCheckColors.foregroundColor,
|
||
checkBorderColor: environment.theme.list.itemCheckColors.strokeColor
|
||
)
|
||
)
|
||
),
|
||
accessibilityLabel: "\(giftTitle). \(accessibilitySubtitle). \(pricePerMonth)",
|
||
isEnabled: product.months > currentProductMonths,
|
||
action: {
|
||
selectProduct(product.id)
|
||
}
|
||
)
|
||
)
|
||
i += 1
|
||
}
|
||
|
||
let optionsSection = optionsSection.update(
|
||
component: SectionGroupComponent(
|
||
items: optionsItems,
|
||
backgroundColor: environment.theme.list.itemBlocksBackgroundColor,
|
||
selectionColor: environment.theme.list.itemHighlightedBackgroundColor,
|
||
separatorColor: environment.theme.list.itemBlocksSeparatorColor
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: availableWidth - sideInsets, height: .greatestFiniteMagnitude),
|
||
transition: context.transition
|
||
)
|
||
context.add(optionsSection
|
||
.position(CGPoint(x: availableWidth / 2.0, y: size.height + optionsSection.size.height / 2.0))
|
||
.clipsToBounds(true)
|
||
.cornerRadius(10.0)
|
||
)
|
||
size.height += optionsSection.size.height
|
||
|
||
if case .emojiStatus = context.component.source {
|
||
size.height -= 18.0
|
||
} else {
|
||
size.height += 26.0
|
||
}
|
||
}
|
||
}
|
||
|
||
let forceDark = context.component.forceDark
|
||
let layoutPerks = {
|
||
var i = 0
|
||
var perksItems: [SectionGroupComponent.Item] = []
|
||
for perk in state.configuration.perks {
|
||
let iconBackgroundColors = gradientColors[i]
|
||
perksItems.append(SectionGroupComponent.Item(
|
||
AnyComponentWithIdentity(
|
||
id: perk.identifier,
|
||
component: AnyComponent(
|
||
PerkComponent(
|
||
iconName: perk.iconName,
|
||
iconBackgroundColors: [
|
||
iconBackgroundColors
|
||
],
|
||
title: perk.title(strings: strings),
|
||
titleColor: titleColor,
|
||
subtitle: perk.subtitle(strings: strings),
|
||
subtitleColor: subtitleColor,
|
||
arrowColor: arrowColor,
|
||
accentColor: accentColor,
|
||
badge: perk.identifier == "stories" ? strings.Premium_New : nil
|
||
)
|
||
)
|
||
),
|
||
accessibilityLabel: "\(perk.title(strings: strings)). \(perk.subtitle(strings: strings))",
|
||
action: { [weak state] in
|
||
var demoSubject: PremiumDemoScreen.Subject
|
||
switch perk {
|
||
case .doubleLimits:
|
||
demoSubject = .doubleLimits
|
||
case .moreUpload:
|
||
demoSubject = .moreUpload
|
||
case .fasterDownload:
|
||
demoSubject = .fasterDownload
|
||
case .voiceToText:
|
||
demoSubject = .voiceToText
|
||
case .noAds:
|
||
demoSubject = .noAds
|
||
case .uniqueReactions:
|
||
demoSubject = .uniqueReactions
|
||
case .premiumStickers:
|
||
demoSubject = .premiumStickers
|
||
case .advancedChatManagement:
|
||
demoSubject = .advancedChatManagement
|
||
case .profileBadge:
|
||
demoSubject = .profileBadge
|
||
case .animatedUserpics:
|
||
demoSubject = .animatedUserpics
|
||
case .appIcons:
|
||
demoSubject = .appIcons
|
||
case .animatedEmoji:
|
||
demoSubject = .animatedEmoji
|
||
case .emojiStatus:
|
||
demoSubject = .emojiStatus
|
||
case .translation:
|
||
demoSubject = .translation
|
||
case .stories:
|
||
demoSubject = .stories
|
||
}
|
||
|
||
let isPremium = state?.isPremium == true
|
||
var dismissImpl: (() -> Void)?
|
||
let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .intro(state?.price), order: state?.configuration.perks, buttonText: isPremium ? strings.Common_OK : (state?.isAnnual == true ? strings.Premium_SubscribeForAnnual(state?.price ?? "—").string : strings.Premium_SubscribeFor(state?.price ?? "–").string), isPremium: isPremium, forceDark: forceDark)
|
||
controller.action = { [weak state] in
|
||
dismissImpl?()
|
||
if state?.isPremium == false {
|
||
buy()
|
||
}
|
||
}
|
||
controller.disposed = {
|
||
updateIsFocused(false)
|
||
}
|
||
present(controller)
|
||
dismissImpl = { [weak controller] in
|
||
controller?.dismiss(animated: true, completion: nil)
|
||
}
|
||
updateIsFocused(true)
|
||
|
||
addAppLogEvent(postbox: accountContext.account.postbox, type: "premium.promo_screen_tap", data: ["item": perk.identifier])
|
||
}
|
||
))
|
||
i += 1
|
||
}
|
||
|
||
let perksSection = perksSection.update(
|
||
component: SectionGroupComponent(
|
||
items: perksItems,
|
||
backgroundColor: environment.theme.list.itemBlocksBackgroundColor,
|
||
selectionColor: environment.theme.list.itemHighlightedBackgroundColor,
|
||
separatorColor: environment.theme.list.itemBlocksSeparatorColor
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: availableWidth - sideInsets, height: .greatestFiniteMagnitude),
|
||
transition: context.transition
|
||
)
|
||
context.add(perksSection
|
||
.position(CGPoint(x: availableWidth / 2.0, y: size.height + perksSection.size.height / 2.0))
|
||
.clipsToBounds(true)
|
||
.cornerRadius(10.0)
|
||
)
|
||
size.height += perksSection.size.height
|
||
|
||
if case .emojiStatus = context.component.source {
|
||
if state.isPremium == true {
|
||
size.height -= 23.0
|
||
} else {
|
||
size.height += 23.0
|
||
}
|
||
} else {
|
||
size.height += 23.0
|
||
}
|
||
}
|
||
|
||
if case .emojiStatus = context.component.source {
|
||
layoutPerks()
|
||
layoutOptions()
|
||
} else {
|
||
layoutOptions()
|
||
layoutPerks()
|
||
|
||
let textSideInset: CGFloat = 16.0
|
||
let textPadding: CGFloat = 13.0
|
||
|
||
let infoTitle = infoTitle.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(
|
||
NSAttributedString(string: strings.Premium_AboutTitle.uppercased(), font: Font.regular(14.0), textColor: environment.theme.list.freeTextColor)
|
||
),
|
||
horizontalAlignment: .natural,
|
||
maximumNumberOfLines: 0,
|
||
lineSpacing: 0.2
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: availableWidth - sideInsets, height: .greatestFiniteMagnitude),
|
||
transition: context.transition
|
||
)
|
||
context.add(infoTitle
|
||
.position(CGPoint(x: sideInset + environment.safeInsets.left + textSideInset + infoTitle.size.width / 2.0, y: size.height + infoTitle.size.height / 2.0))
|
||
)
|
||
size.height += infoTitle.size.height
|
||
size.height += 3.0
|
||
|
||
let infoText = infoText.update(
|
||
component: MultilineTextComponent(
|
||
text: .markdown(
|
||
text: strings.Premium_AboutText,
|
||
attributes: markdownAttributes
|
||
),
|
||
horizontalAlignment: .natural,
|
||
maximumNumberOfLines: 0,
|
||
lineSpacing: 0.2
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: availableWidth - sideInsets - textSideInset * 2.0, height: .greatestFiniteMagnitude),
|
||
transition: context.transition
|
||
)
|
||
|
||
let infoBackground = infoBackground.update(
|
||
component: RoundedRectangle(
|
||
color: environment.theme.list.itemBlocksBackgroundColor,
|
||
cornerRadius: 10.0
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: availableWidth - sideInsets, height: infoText.size.height + textPadding * 2.0),
|
||
transition: context.transition
|
||
)
|
||
context.add(infoBackground
|
||
.position(CGPoint(x: size.width / 2.0, y: size.height + infoBackground.size.height / 2.0))
|
||
)
|
||
context.add(infoText
|
||
.position(CGPoint(x: sideInset + environment.safeInsets.left + textSideInset + infoText.size.width / 2.0, y: size.height + textPadding + infoText.size.height / 2.0))
|
||
)
|
||
size.height += infoBackground.size.height
|
||
size.height += 6.0
|
||
|
||
let termsFont = Font.regular(13.0)
|
||
let boldTermsFont = Font.semibold(13.0)
|
||
let italicTermsFont = Font.italic(13.0)
|
||
let boldItalicTermsFont = Font.semiboldItalic(13.0)
|
||
let monospaceTermsFont = Font.monospace(13.0)
|
||
let termsTextColor = environment.theme.list.freeTextColor
|
||
let termsMarkdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: termsFont, textColor: termsTextColor), bold: MarkdownAttributeSet(font: termsFont, textColor: termsTextColor), link: MarkdownAttributeSet(font: termsFont, textColor: environment.theme.list.itemAccentColor), linkAttribute: { contents in
|
||
return (TelegramTextAttributes.URL, contents)
|
||
})
|
||
|
||
var isGiftView = false
|
||
if case let .gift(fromId, _, _) = context.component.source {
|
||
if fromId == context.component.context.account.peerId {
|
||
isGiftView = true
|
||
}
|
||
}
|
||
|
||
let termsString: MultilineTextComponent.TextContent
|
||
if isGiftView {
|
||
termsString = .plain(NSAttributedString())
|
||
} else if let promoConfiguration = context.component.promoConfiguration {
|
||
let attributedString = stringWithAppliedEntities(promoConfiguration.status, entities: promoConfiguration.statusEntities, baseColor: termsTextColor, linkColor: environment.theme.list.itemAccentColor, baseFont: termsFont, linkFont: termsFont, boldFont: boldTermsFont, italicFont: italicTermsFont, boldItalicFont: boldItalicTermsFont, fixedFont: monospaceTermsFont, blockQuoteFont: termsFont, message: nil)
|
||
termsString = .plain(attributedString)
|
||
} else {
|
||
termsString = .markdown(
|
||
text: strings.Premium_Terms,
|
||
attributes: termsMarkdownAttributes
|
||
)
|
||
}
|
||
|
||
let controller = environment.controller
|
||
let termsTapActionImpl: ([NSAttributedString.Key: Any]) -> Void = { attributes in
|
||
if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String,
|
||
let controller = controller() as? PremiumIntroScreen, let navigationController = controller.navigationController as? NavigationController {
|
||
if url.hasPrefix("https://apps.apple.com/account/subscriptions") {
|
||
controller.context.sharedContext.applicationBindings.openSubscriptions()
|
||
} else if url.hasPrefix("https://") || url.hasPrefix("tg://") {
|
||
controller.context.sharedContext.openExternalUrl(context: controller.context, urlContext: .generic, url: url, forceExternal: !url.hasPrefix("tg://"), presentationData: controller.context.sharedContext.currentPresentationData.with({$0}), navigationController: nil, dismissInput: {})
|
||
} else {
|
||
let context = controller.context
|
||
let signal: Signal<ResolvedUrl, NoError>?
|
||
switch url {
|
||
case "terms":
|
||
signal = cachedTermsPage(context: context)
|
||
case "privacy":
|
||
signal = cachedPrivacyPage(context: context)
|
||
default:
|
||
signal = nil
|
||
}
|
||
if let signal = signal {
|
||
let _ = (signal
|
||
|> deliverOnMainQueue).start(next: { resolvedUrl in
|
||
context.sharedContext.openResolvedUrl(resolvedUrl, context: context, urlContext: .generic, navigationController: navigationController, forceExternal: false, openPeer: { peer, navigation in
|
||
}, sendFile: nil, sendSticker: nil, requestMessageActionUrlAuth: nil, joinVoiceChat: nil, present: { [weak controller] c, arguments in
|
||
controller?.push(c)
|
||
}, dismissInput: {}, contentContext: nil)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let termsText = termsText.update(
|
||
component: MultilineTextComponent(
|
||
text: termsString,
|
||
horizontalAlignment: .natural,
|
||
maximumNumberOfLines: 0,
|
||
lineSpacing: 0.0,
|
||
highlightColor: environment.theme.list.itemAccentColor.withAlphaComponent(0.2),
|
||
highlightAction: { attributes in
|
||
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
|
||
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
|
||
} else {
|
||
return nil
|
||
}
|
||
},
|
||
tapAction: { attributes, _ in
|
||
termsTapActionImpl(attributes)
|
||
}
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: availableWidth - sideInsets - textSideInset * 2.0, height: .greatestFiniteMagnitude),
|
||
transition: context.transition
|
||
)
|
||
context.add(termsText
|
||
.position(CGPoint(x: sideInset + environment.safeInsets.left + textSideInset + termsText.size.width / 2.0, y: size.height + termsText.size.height / 2.0))
|
||
)
|
||
size.height += termsText.size.height
|
||
size.height += 10.0
|
||
}
|
||
|
||
size.height += scrollEnvironment.insets.bottom
|
||
|
||
if context.component.source != .settings {
|
||
size.height += 44.0
|
||
}
|
||
|
||
return size
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class PremiumIntroScreenComponent: CombinedComponent {
|
||
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
||
|
||
let context: AccountContext
|
||
let source: PremiumSource
|
||
let forceDark: Bool
|
||
let updateInProgress: (Bool) -> Void
|
||
let present: (ViewController) -> Void
|
||
let push: (ViewController) -> Void
|
||
let completion: () -> Void
|
||
|
||
init(context: AccountContext, source: PremiumSource, forceDark: Bool, updateInProgress: @escaping (Bool) -> Void, present: @escaping (ViewController) -> Void, push: @escaping (ViewController) -> Void, completion: @escaping () -> Void) {
|
||
self.context = context
|
||
self.source = source
|
||
self.forceDark = forceDark
|
||
self.updateInProgress = updateInProgress
|
||
self.present = present
|
||
self.push = push
|
||
self.completion = completion
|
||
}
|
||
|
||
static func ==(lhs: PremiumIntroScreenComponent, rhs: PremiumIntroScreenComponent) -> Bool {
|
||
if lhs.context !== rhs.context {
|
||
return false
|
||
}
|
||
if lhs.source != rhs.source {
|
||
return false
|
||
}
|
||
if lhs.forceDark != rhs.forceDark {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
final class State: ComponentState {
|
||
private let context: AccountContext
|
||
private let updateInProgress: (Bool) -> Void
|
||
private let present: (ViewController) -> Void
|
||
private let completion: () -> Void
|
||
|
||
var topContentOffset: CGFloat?
|
||
var bottomContentOffset: CGFloat?
|
||
|
||
var hasIdleAnimations = true
|
||
|
||
var inProgress = false
|
||
|
||
private(set) var promoConfiguration: PremiumPromoConfiguration?
|
||
|
||
private(set) var products: [PremiumProduct]?
|
||
private(set) var selectedProductId: String?
|
||
fileprivate var validPurchases: [InAppPurchaseManager.ReceiptPurchase] = []
|
||
|
||
var isPremium: Bool?
|
||
var otherPeerName: String?
|
||
var justBought = false
|
||
|
||
let animationCache: AnimationCache
|
||
let animationRenderer: MultiAnimationRenderer
|
||
|
||
var emojiFile: TelegramMediaFile?
|
||
var emojiPackTitle: String?
|
||
private var emojiFileDisposable: Disposable?
|
||
|
||
|
||
private var disposable: Disposable?
|
||
private var paymentDisposable = MetaDisposable()
|
||
private var activationDisposable = MetaDisposable()
|
||
private var preloadDisposableSet = DisposableSet()
|
||
|
||
var price: String? {
|
||
return self.products?.first(where: { $0.id == self.selectedProductId })?.price
|
||
}
|
||
|
||
var isAnnual: Bool {
|
||
return self.products?.first(where: { $0.id == self.selectedProductId })?.id.hasSuffix(".annual") ?? false
|
||
}
|
||
|
||
var canUpgrade: Bool {
|
||
if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId {
|
||
if self.validPurchases.contains(where: { $0.transactionId == transactionId }) {
|
||
return products.first(where: { $0.months > current.months }) != nil
|
||
} else {
|
||
return false
|
||
}
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
|
||
init(context: AccountContext, source: PremiumSource, updateInProgress: @escaping (Bool) -> Void, present: @escaping (ViewController) -> Void, completion: @escaping () -> Void) {
|
||
self.context = context
|
||
self.updateInProgress = updateInProgress
|
||
self.present = present
|
||
self.completion = completion
|
||
|
||
self.animationCache = context.animationCache
|
||
self.animationRenderer = context.animationRenderer
|
||
|
||
super.init()
|
||
|
||
self.validPurchases = context.inAppPurchaseManager?.getReceiptPurchases() ?? []
|
||
|
||
let availableProducts: Signal<[InAppPurchaseManager.Product], NoError>
|
||
if let inAppPurchaseManager = context.inAppPurchaseManager {
|
||
availableProducts = inAppPurchaseManager.availableProducts
|
||
} else {
|
||
availableProducts = .single([])
|
||
}
|
||
|
||
let otherPeerName: Signal<String?, NoError>
|
||
if case let .gift(fromPeerId, toPeerId, _) = source {
|
||
let otherPeerId = fromPeerId != context.account.peerId ? fromPeerId : toPeerId
|
||
otherPeerName = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: otherPeerId))
|
||
|> map { peer -> String? in
|
||
return peer?.compactDisplayTitle
|
||
}
|
||
} else if case let .profile(peerId) = source {
|
||
otherPeerName = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|
||
|> map { peer -> String? in
|
||
return peer?.compactDisplayTitle
|
||
}
|
||
} else if case let .emojiStatus(peerId, _, _, _) = source {
|
||
otherPeerName = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|
||
|> map { peer -> String? in
|
||
return peer?.compactDisplayTitle
|
||
}
|
||
} else {
|
||
otherPeerName = .single(nil)
|
||
}
|
||
|
||
self.disposable = combineLatest(
|
||
queue: Queue.mainQueue(),
|
||
availableProducts,
|
||
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.PremiumPromo()),
|
||
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|
||
|> map { peer -> Bool in
|
||
return peer?.isPremium ?? false
|
||
},
|
||
otherPeerName
|
||
).start(next: { [weak self] availableProducts, promoConfiguration, isPremium, otherPeerName in
|
||
if let strongSelf = self {
|
||
strongSelf.promoConfiguration = promoConfiguration
|
||
|
||
let hadProducts = strongSelf.products != nil
|
||
|
||
var products: [PremiumProduct] = []
|
||
for option in promoConfiguration.premiumProductOptions {
|
||
if let product = availableProducts.first(where: { $0.id == option.storeProductId }), product.isSubscription {
|
||
products.append(PremiumProduct(option: option, storeProduct: product))
|
||
}
|
||
}
|
||
|
||
strongSelf.products = products
|
||
strongSelf.isPremium = isPremium
|
||
strongSelf.otherPeerName = otherPeerName
|
||
|
||
if !hadProducts {
|
||
strongSelf.selectedProductId = strongSelf.products?.first?.id
|
||
|
||
for (_, video) in promoConfiguration.videos {
|
||
strongSelf.preloadDisposableSet.add(preloadVideoResource(postbox: context.account.postbox, userLocation: .other, userContentType: .video, resourceReference: .standalone(resource: video.resource), duration: 3.0).start())
|
||
}
|
||
}
|
||
|
||
strongSelf.updated(transition: .immediate)
|
||
}
|
||
})
|
||
|
||
if case let .emojiStatus(_, emojiFileId, emojiFile, maybeEmojiPack) = source, let emojiPack = maybeEmojiPack, case let .result(info, _, _) = emojiPack {
|
||
if let emojiFile = emojiFile {
|
||
self.emojiFile = emojiFile
|
||
self.emojiPackTitle = info.title
|
||
self.updated(transition: .immediate)
|
||
} else {
|
||
self.emojiFileDisposable = (context.engine.stickers.resolveInlineStickers(fileIds: [emojiFileId])
|
||
|> deliverOnMainQueue).start(next: { [weak self] result in
|
||
guard let strongSelf = self else {
|
||
return
|
||
}
|
||
strongSelf.emojiFile = result[emojiFileId]
|
||
strongSelf.updated(transition: .immediate)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
self.disposable?.dispose()
|
||
self.paymentDisposable.dispose()
|
||
self.activationDisposable.dispose()
|
||
self.emojiFileDisposable?.dispose()
|
||
self.preloadDisposableSet.dispose()
|
||
}
|
||
|
||
func buy() {
|
||
guard let inAppPurchaseManager = self.context.inAppPurchaseManager,
|
||
let premiumProduct = self.products?.first(where: { $0.id == self.selectedProductId }), !self.inProgress else {
|
||
return
|
||
}
|
||
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
||
|
||
let isUpgrade = self.products?.first(where: { $0.isCurrent }) != nil
|
||
|
||
var hasActiveSubsciption = false
|
||
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_receipt_check"] {
|
||
|
||
} else if !self.validPurchases.isEmpty && !isUpgrade {
|
||
let now = Date()
|
||
for purchase in self.validPurchases.reversed() {
|
||
if (purchase.productId.hasSuffix(".monthly") || purchase.productId.hasSuffix(".annual")) && purchase.expirationDate > now {
|
||
hasActiveSubsciption = true
|
||
}
|
||
}
|
||
}
|
||
|
||
if hasActiveSubsciption {
|
||
let errorText = presentationData.strings.Premium_Purchase_OnlyOneSubscriptionAllowed
|
||
let alertController = textAlertController(context: self.context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})])
|
||
self.present(alertController)
|
||
return
|
||
}
|
||
|
||
addAppLogEvent(postbox: self.context.account.postbox, type: "premium.promo_screen_accept")
|
||
|
||
self.inProgress = true
|
||
self.updateInProgress(true)
|
||
self.updated(transition: .immediate)
|
||
|
||
let purpose: AppStoreTransactionPurpose = isUpgrade ? .upgrade : .subscription
|
||
let _ = (self.context.engine.payments.canPurchasePremium(purpose: purpose)
|
||
|> deliverOnMainQueue).start(next: { [weak self] available in
|
||
if let strongSelf = self {
|
||
if available {
|
||
strongSelf.paymentDisposable.set((inAppPurchaseManager.buyProduct(premiumProduct.storeProduct, purpose: purpose)
|
||
|> deliverOnMainQueue).start(next: { [weak self] status in
|
||
if let strongSelf = self, case .purchased = status {
|
||
strongSelf.activationDisposable.set((strongSelf.context.account.postbox.peerView(id: strongSelf.context.account.peerId)
|
||
|> castError(AssignAppStoreTransactionError.self)
|
||
|> take(until: { view in
|
||
if let peer = view.peers[view.peerId], peer.isPremium {
|
||
return SignalTakeAction(passthrough: false, complete: true)
|
||
} else {
|
||
return SignalTakeAction(passthrough: false, complete: false)
|
||
}
|
||
})
|
||
|> mapToSignal { _ -> Signal<Never, AssignAppStoreTransactionError> in
|
||
return .never()
|
||
}
|
||
|> timeout(15.0, queue: Queue.mainQueue(), alternate: .fail(.timeout))
|
||
|> deliverOnMainQueue).start(error: { [weak self] _ in
|
||
if let strongSelf = self {
|
||
strongSelf.inProgress = false
|
||
strongSelf.updateInProgress(false)
|
||
|
||
strongSelf.updated(transition: .immediate)
|
||
|
||
addAppLogEvent(postbox: strongSelf.context.account.postbox, type: "premium.promo_screen_fail")
|
||
|
||
let errorText = presentationData.strings.Premium_Purchase_ErrorUnknown
|
||
let alertController = textAlertController(context: strongSelf.context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})])
|
||
strongSelf.present(alertController)
|
||
}
|
||
}, completed: { [weak self] in
|
||
if let strongSelf = self {
|
||
let _ = updatePremiumPromoConfigurationOnce(account: strongSelf.context.account).start()
|
||
strongSelf.inProgress = false
|
||
strongSelf.updateInProgress(false)
|
||
|
||
strongSelf.isPremium = true
|
||
strongSelf.justBought = true
|
||
|
||
strongSelf.updated(transition: .easeInOut(duration: 0.25))
|
||
strongSelf.completion()
|
||
}
|
||
}))
|
||
}
|
||
}, error: { [weak self] error in
|
||
if let strongSelf = self {
|
||
strongSelf.inProgress = false
|
||
strongSelf.updateInProgress(false)
|
||
strongSelf.updated(transition: .immediate)
|
||
|
||
var errorText: String?
|
||
switch error {
|
||
case .generic:
|
||
errorText = presentationData.strings.Premium_Purchase_ErrorUnknown
|
||
case .network:
|
||
errorText = presentationData.strings.Premium_Purchase_ErrorNetwork
|
||
case .notAllowed:
|
||
errorText = presentationData.strings.Premium_Purchase_ErrorNotAllowed
|
||
case .cantMakePayments:
|
||
errorText = presentationData.strings.Premium_Purchase_ErrorCantMakePayments
|
||
case .assignFailed:
|
||
errorText = presentationData.strings.Premium_Purchase_ErrorUnknown
|
||
case .cancelled:
|
||
break
|
||
}
|
||
|
||
if let errorText = errorText {
|
||
addAppLogEvent(postbox: strongSelf.context.account.postbox, type: "premium.promo_screen_fail")
|
||
|
||
let alertController = textAlertController(context: strongSelf.context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})])
|
||
strongSelf.present(alertController)
|
||
}
|
||
}
|
||
}))
|
||
} else {
|
||
strongSelf.inProgress = false
|
||
strongSelf.updateInProgress(false)
|
||
strongSelf.updated(transition: .immediate)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func updateIsFocused(_ isFocused: Bool) {
|
||
self.hasIdleAnimations = !isFocused
|
||
self.updated(transition: .immediate)
|
||
}
|
||
|
||
func selectProduct(_ productId: String) {
|
||
self.selectedProductId = productId
|
||
self.updated(transition: .immediate)
|
||
}
|
||
}
|
||
|
||
func makeState() -> State {
|
||
return State(context: self.context, source: self.source, updateInProgress: self.updateInProgress, present: self.present, completion: self.completion)
|
||
}
|
||
|
||
static var body: Body {
|
||
let background = Child(Rectangle.self)
|
||
let scrollContent = Child(ScrollComponent<EnvironmentType>.self)
|
||
let star = Child(PremiumStarComponent.self)
|
||
let emoji = Child(EmojiHeaderComponent.self)
|
||
let topPanel = Child(BlurredBackgroundComponent.self)
|
||
let topSeparator = Child(Rectangle.self)
|
||
let title = Child(MultilineTextComponent.self)
|
||
let secondaryTitle = Child(MultilineTextWithEntitiesComponent.self)
|
||
let bottomPanel = Child(BlurredBackgroundComponent.self)
|
||
let bottomSeparator = Child(Rectangle.self)
|
||
let button = Child(SolidRoundedButtonComponent.self)
|
||
|
||
return { context in
|
||
let environment = context.environment[EnvironmentType.self].value
|
||
let state = context.state
|
||
|
||
let background = background.update(component: Rectangle(color: environment.theme.list.blocksBackgroundColor), environment: {}, availableSize: context.availableSize, transition: context.transition)
|
||
|
||
var starIsVisible = true
|
||
if let topContentOffset = state.topContentOffset, topContentOffset >= 123.0 {
|
||
starIsVisible = false
|
||
}
|
||
|
||
var isIntro = true
|
||
if case .profile = context.component.source {
|
||
isIntro = false
|
||
}
|
||
|
||
let header: _UpdatedChildComponent
|
||
if case let .emojiStatus(_, fileId, _, _) = context.component.source {
|
||
header = emoji.update(
|
||
component: EmojiHeaderComponent(
|
||
context: context.component.context,
|
||
animationCache: state.animationCache,
|
||
animationRenderer: state.animationRenderer,
|
||
placeholderColor: environment.theme.list.mediaPlaceholderColor,
|
||
accentColor: environment.theme.list.itemAccentColor,
|
||
fileId: fileId,
|
||
isVisible: starIsVisible,
|
||
hasIdleAnimations: state.hasIdleAnimations
|
||
),
|
||
availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0),
|
||
transition: context.transition
|
||
)
|
||
} else {
|
||
header = star.update(
|
||
component: PremiumStarComponent(
|
||
isIntro: isIntro,
|
||
isVisible: starIsVisible,
|
||
hasIdleAnimations: state.hasIdleAnimations
|
||
),
|
||
availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0),
|
||
transition: context.transition
|
||
)
|
||
}
|
||
|
||
let topPanel = topPanel.update(
|
||
component: BlurredBackgroundComponent(
|
||
color: environment.theme.rootController.navigationBar.blurredBackgroundColor
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width, height: environment.navigationHeight),
|
||
transition: context.transition
|
||
)
|
||
|
||
let topSeparator = topSeparator.update(
|
||
component: Rectangle(
|
||
color: environment.theme.rootController.navigationBar.separatorColor
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width, height: UIScreenPixel),
|
||
transition: context.transition
|
||
)
|
||
|
||
let titleString: String
|
||
if case .emojiStatus = context.component.source {
|
||
titleString = environment.strings.Premium_Title
|
||
} else if case .giftTerms = context.component.source {
|
||
titleString = environment.strings.Premium_Title
|
||
} else if case .gift = context.component.source {
|
||
titleString = environment.strings.Premium_GiftedTitle
|
||
} else if state.isPremium == true {
|
||
if !state.justBought && state.canUpgrade {
|
||
titleString = environment.strings.Premium_Title
|
||
} else {
|
||
titleString = environment.strings.Premium_SubscribedTitle
|
||
}
|
||
} else {
|
||
titleString = environment.strings.Premium_Title
|
||
}
|
||
|
||
let title = title.update(
|
||
component: MultilineTextComponent(
|
||
text: .plain(NSAttributedString(string: titleString, font: Font.bold(28.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)),
|
||
horizontalAlignment: .center,
|
||
truncationType: .end,
|
||
maximumNumberOfLines: 1
|
||
),
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
var loadedEmojiPack: LoadedStickerPack?
|
||
var highlightableLinks = false
|
||
let secondaryTitleText: String
|
||
var isAnonymous = false
|
||
if var otherPeerName = state.otherPeerName {
|
||
if case let .emojiStatus(_, _, file, maybeEmojiPack) = context.component.source, let emojiPack = maybeEmojiPack, case let .result(info, _, _) = emojiPack {
|
||
loadedEmojiPack = maybeEmojiPack
|
||
highlightableLinks = true
|
||
|
||
var packReference: StickerPackReference?
|
||
if let file = file {
|
||
for attribute in file.attributes {
|
||
if case let .CustomEmoji(_, _, _, reference) = attribute {
|
||
packReference = reference
|
||
}
|
||
}
|
||
}
|
||
if let packReference = packReference, case let .id(id, _) = packReference, id == 773947703670341676 {
|
||
secondaryTitleText = environment.strings.Premium_EmojiStatusShortTitle(otherPeerName).string
|
||
} else {
|
||
secondaryTitleText = environment.strings.Premium_EmojiStatusTitle(otherPeerName, info.title).string.replacingOccurrences(of: "#", with: " # ")
|
||
}
|
||
} else if case .profile = context.component.source {
|
||
secondaryTitleText = environment.strings.Premium_PersonalTitle(otherPeerName).string
|
||
} else if case let .gift(fromPeerId, _, duration) = context.component.source {
|
||
if fromPeerId == context.component.context.account.peerId {
|
||
if duration == 12 {
|
||
secondaryTitleText = environment.strings.Premium_GiftedTitleYou_12Month(otherPeerName).string
|
||
} else if duration == 6 {
|
||
secondaryTitleText = environment.strings.Premium_GiftedTitleYou_6Month(otherPeerName).string
|
||
} else if duration == 3 {
|
||
secondaryTitleText = environment.strings.Premium_GiftedTitleYou_3Month(otherPeerName).string
|
||
} else {
|
||
secondaryTitleText = ""
|
||
}
|
||
} else {
|
||
if fromPeerId.namespace == Namespaces.Peer.CloudUser && fromPeerId.id._internalGetInt64Value() == 777000 {
|
||
isAnonymous = true
|
||
otherPeerName = environment.strings.Premium_GiftedTitle_Someone
|
||
}
|
||
if duration == 12 {
|
||
secondaryTitleText = environment.strings.Premium_GiftedTitle_12Month(otherPeerName).string
|
||
} else if duration == 6 {
|
||
secondaryTitleText = environment.strings.Premium_GiftedTitle_6Month(otherPeerName).string
|
||
} else if duration == 3 {
|
||
secondaryTitleText = environment.strings.Premium_GiftedTitle_3Month(otherPeerName).string
|
||
} else {
|
||
secondaryTitleText = ""
|
||
}
|
||
}
|
||
} else {
|
||
secondaryTitleText = ""
|
||
}
|
||
} else {
|
||
secondaryTitleText = ""
|
||
}
|
||
|
||
let textColor = environment.theme.list.itemPrimaryTextColor
|
||
let accentColor: UIColor
|
||
if case .emojiStatus = context.component.source {
|
||
accentColor = environment.theme.list.itemAccentColor
|
||
} else {
|
||
accentColor = UIColor(rgb: 0x597cf5)
|
||
}
|
||
|
||
let textFont = Font.bold(18.0)
|
||
let boldTextFont = Font.bold(18.0)
|
||
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: isAnonymous ? textColor : accentColor), linkAttribute: { _ in
|
||
return nil
|
||
})
|
||
|
||
let secondaryAttributedText = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(secondaryTitleText, attributes: markdownAttributes))
|
||
if let emojiFile = state.emojiFile {
|
||
let range = (secondaryAttributedText.string as NSString).range(of: "#")
|
||
if range.location != NSNotFound {
|
||
secondaryAttributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: emojiFile.fileId.id, file: emojiFile), range: range)
|
||
}
|
||
}
|
||
let accountContext = context.component.context
|
||
let presentController = context.component.present
|
||
|
||
let secondaryTitle = secondaryTitle.update(
|
||
component: MultilineTextWithEntitiesComponent(
|
||
context: context.component.context,
|
||
animationCache: context.state.animationCache,
|
||
animationRenderer: context.state.animationRenderer,
|
||
placeholderColor: environment.theme.list.mediaPlaceholderColor,
|
||
text: .plain(secondaryAttributedText),
|
||
horizontalAlignment: .center,
|
||
truncationType: .end,
|
||
maximumNumberOfLines: 2,
|
||
lineSpacing: 0.0,
|
||
highlightAction: highlightableLinks ? { attributes in
|
||
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
|
||
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
|
||
} else {
|
||
return nil
|
||
}
|
||
} : nil,
|
||
tapAction: { [weak state, weak environment] _, _ in
|
||
if let emojiFile = state?.emojiFile, let controller = environment?.controller() as? PremiumIntroScreen, let navigationController = controller.navigationController as? NavigationController {
|
||
for attribute in emojiFile.attributes {
|
||
if case let .CustomEmoji(_, _, _, packReference) = attribute, let packReference = packReference {
|
||
let controller = accountContext.sharedContext.makeStickerPackScreen(context: accountContext, updatedPresentationData: nil, mainStickerPack: packReference, stickerPacks: [packReference], loadedStickerPacks: loadedEmojiPack.flatMap { [$0] } ?? [], parentNavigationController: navigationController, sendSticker: { _, _, _ in
|
||
return false
|
||
})
|
||
presentController(controller)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - 32.0, height: context.availableSize.width),
|
||
transition: context.transition
|
||
)
|
||
|
||
let bottomPanelPadding: CGFloat = 12.0
|
||
let bottomInset: CGFloat = environment.safeInsets.bottom > 0.0 ? environment.safeInsets.bottom + 5.0 : bottomPanelPadding
|
||
let bottomPanelHeight: CGFloat = state.isPremium == true && !state.canUpgrade ? bottomInset : bottomPanelPadding + 50.0 + bottomInset
|
||
|
||
let scrollContent = scrollContent.update(
|
||
component: ScrollComponent<EnvironmentType>(
|
||
content: AnyComponent(PremiumIntroScreenContentComponent(
|
||
context: context.component.context,
|
||
source: context.component.source,
|
||
forceDark: context.component.forceDark,
|
||
isPremium: state.isPremium,
|
||
justBought: state.justBought,
|
||
otherPeerName: state.otherPeerName,
|
||
products: state.products,
|
||
selectedProductId: state.selectedProductId,
|
||
validPurchases: state.validPurchases,
|
||
promoConfiguration: state.promoConfiguration,
|
||
present: context.component.present,
|
||
selectProduct: { [weak state] productId in
|
||
state?.selectProduct(productId)
|
||
},
|
||
buy: { [weak state] in
|
||
state?.buy()
|
||
},
|
||
updateIsFocused: { [weak state] isFocused in
|
||
state?.updateIsFocused(isFocused)
|
||
}
|
||
)),
|
||
contentInsets: UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: bottomPanelHeight, right: 0.0),
|
||
contentOffsetUpdated: { [weak state] topContentOffset, bottomContentOffset in
|
||
state?.topContentOffset = topContentOffset
|
||
state?.bottomContentOffset = bottomContentOffset
|
||
Queue.mainQueue().justDispatch {
|
||
state?.updated(transition: .immediate)
|
||
}
|
||
},
|
||
contentOffsetWillCommit: { targetContentOffset in
|
||
if targetContentOffset.pointee.y < 100.0 {
|
||
targetContentOffset.pointee = CGPoint(x: 0.0, y: 0.0)
|
||
} else if targetContentOffset.pointee.y < 123.0 {
|
||
targetContentOffset.pointee = CGPoint(x: 0.0, y: 123.0)
|
||
}
|
||
}
|
||
),
|
||
environment: { environment },
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
let topInset: CGFloat = environment.navigationHeight - 56.0
|
||
|
||
context.add(background
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
|
||
)
|
||
|
||
context.add(scrollContent
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
|
||
)
|
||
|
||
let topPanelAlpha: CGFloat
|
||
let titleOffset: CGFloat
|
||
let titleScale: CGFloat
|
||
let titleOffsetDelta = (topInset + 160.0) - (environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0)
|
||
let titleAlpha: CGFloat
|
||
|
||
if let topContentOffset = state.topContentOffset {
|
||
topPanelAlpha = min(20.0, max(0.0, topContentOffset - 95.0)) / 20.0
|
||
let topContentOffset = topContentOffset + max(0.0, min(1.0, topContentOffset / titleOffsetDelta)) * 10.0
|
||
titleOffset = topContentOffset
|
||
let fraction = max(0.0, min(1.0, titleOffset / titleOffsetDelta))
|
||
titleScale = 1.0 - fraction * 0.36
|
||
|
||
if state.otherPeerName != nil {
|
||
titleAlpha = min(1.0, fraction * 1.1)
|
||
} else {
|
||
titleAlpha = 1.0
|
||
}
|
||
} else {
|
||
topPanelAlpha = 0.0
|
||
titleScale = 1.0
|
||
titleOffset = 0.0
|
||
titleAlpha = state.otherPeerName != nil ? 0.0 : 1.0
|
||
}
|
||
|
||
context.add(header
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: topInset + header.size.height / 2.0 - 30.0 - titleOffset * titleScale))
|
||
.scale(titleScale)
|
||
)
|
||
|
||
context.add(topPanel
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height / 2.0))
|
||
.opacity(topPanelAlpha)
|
||
)
|
||
context.add(topSeparator
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height))
|
||
.opacity(topPanelAlpha)
|
||
)
|
||
|
||
context.add(title
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: max(topInset + 160.0 - titleOffset, environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0)))
|
||
.scale(titleScale)
|
||
.opacity(titleAlpha)
|
||
)
|
||
|
||
context.add(secondaryTitle
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: max(topInset + 160.0 - titleOffset, environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0)))
|
||
.scale(titleScale)
|
||
.opacity(max(0.0, 1.0 - titleAlpha * 1.8))
|
||
)
|
||
|
||
var isGiftView = false
|
||
if case let .gift(fromId, _, _) = context.component.source {
|
||
if fromId == context.component.context.account.peerId {
|
||
isGiftView = true
|
||
}
|
||
}
|
||
|
||
if (state.isPremium == true && (!state.canUpgrade || state.justBought)) || isGiftView {
|
||
|
||
} else {
|
||
let buttonTitle: String
|
||
if state.isPremium == true && state.canUpgrade {
|
||
buttonTitle = state.isAnnual ? environment.strings.Premium_UpgradeForAnnual(state.price ?? "—").string : environment.strings.Premium_UpgradeFor(state.price ?? "—").string
|
||
} else {
|
||
buttonTitle = state.isAnnual ? environment.strings.Premium_SubscribeForAnnual(state.price ?? "—").string : environment.strings.Premium_SubscribeFor(state.price ?? "—").string
|
||
}
|
||
|
||
let sideInset: CGFloat = 16.0
|
||
let button = button.update(
|
||
component: SolidRoundedButtonComponent(
|
||
title: buttonTitle,
|
||
theme: SolidRoundedButtonComponent.Theme(
|
||
backgroundColor: UIColor(rgb: 0x8878ff),
|
||
backgroundColors: [
|
||
UIColor(rgb: 0x0077ff),
|
||
UIColor(rgb: 0x6b93ff),
|
||
UIColor(rgb: 0x8878ff),
|
||
UIColor(rgb: 0xe46ace)
|
||
],
|
||
foregroundColor: .white
|
||
),
|
||
height: 50.0,
|
||
cornerRadius: 11.0,
|
||
gloss: true,
|
||
isLoading: state.inProgress,
|
||
action: {
|
||
state.buy()
|
||
}
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - environment.safeInsets.left - environment.safeInsets.right, height: 50.0),
|
||
transition: context.transition)
|
||
|
||
let bottomPanel = bottomPanel.update(
|
||
component: BlurredBackgroundComponent(
|
||
color: environment.theme.rootController.tabBar.backgroundColor
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width, height: bottomPanelPadding + button.size.height + bottomInset),
|
||
transition: context.transition
|
||
)
|
||
|
||
let bottomSeparator = bottomSeparator.update(
|
||
component: Rectangle(
|
||
color: environment.theme.rootController.tabBar.separatorColor
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width, height: UIScreenPixel),
|
||
transition: context.transition
|
||
)
|
||
|
||
let bottomPanelAlpha: CGFloat
|
||
if let bottomContentOffset = state.bottomContentOffset {
|
||
bottomPanelAlpha = min(16.0, bottomContentOffset) / 16.0
|
||
} else {
|
||
bottomPanelAlpha = 1.0
|
||
}
|
||
|
||
context.add(bottomPanel
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height / 2.0))
|
||
.opacity(bottomPanelAlpha)
|
||
.disappear(Transition.Disappear { view, transition, completion in
|
||
if case .none = transition.animation {
|
||
completion()
|
||
return
|
||
}
|
||
view.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: bottomPanel.size.height), duration: 0.2, removeOnCompletion: false, additive: true, completion: { _ in
|
||
completion()
|
||
})
|
||
})
|
||
)
|
||
context.add(bottomSeparator
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height))
|
||
.opacity(bottomPanelAlpha)
|
||
.disappear(Transition.Disappear { view, transition, completion in
|
||
if case .none = transition.animation {
|
||
completion()
|
||
return
|
||
}
|
||
view.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: bottomPanel.size.height), duration: 0.2, removeOnCompletion: false, additive: true, completion: { _ in
|
||
completion()
|
||
})
|
||
})
|
||
)
|
||
context.add(button
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height + bottomPanelPadding + button.size.height / 2.0))
|
||
.disappear(Transition.Disappear { view, transition, completion in
|
||
if case .none = transition.animation {
|
||
completion()
|
||
return
|
||
}
|
||
view.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: bottomPanel.size.height), duration: 0.2, removeOnCompletion: false, additive: true, completion: { _ in
|
||
completion()
|
||
})
|
||
})
|
||
)
|
||
}
|
||
|
||
return context.availableSize
|
||
}
|
||
}
|
||
}
|
||
|
||
public final class PremiumIntroScreen: ViewControllerComponentContainer {
|
||
fileprivate let context: AccountContext
|
||
|
||
private var didSetReady = false
|
||
private let _ready = Promise<Bool>()
|
||
public override var ready: Promise<Bool> {
|
||
return self._ready
|
||
}
|
||
|
||
public weak var sourceView: UIView?
|
||
public weak var containerView: UIView?
|
||
public var animationColor: UIColor?
|
||
|
||
public init(context: AccountContext, modal: Bool = true, source: PremiumSource, forceDark: Bool = false) {
|
||
self.context = context
|
||
|
||
var updateInProgressImpl: ((Bool) -> Void)?
|
||
var pushImpl: ((ViewController) -> Void)?
|
||
var presentImpl: ((ViewController) -> Void)?
|
||
var completionImpl: (() -> Void)?
|
||
super.init(context: context, component: PremiumIntroScreenComponent(
|
||
context: context,
|
||
source: source,
|
||
forceDark: forceDark,
|
||
updateInProgress: { inProgress in
|
||
updateInProgressImpl?(inProgress)
|
||
},
|
||
present: { c in
|
||
presentImpl?(c)
|
||
},
|
||
push: { c in
|
||
pushImpl?(c)
|
||
},
|
||
completion: {
|
||
completionImpl?()
|
||
}
|
||
), navigationBarAppearance: .transparent, presentationMode: modal ? .modal : .default, theme: forceDark ? .dark : .default)
|
||
|
||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||
|
||
if modal {
|
||
let cancelItem = UIBarButtonItem(title: presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(self.cancelPressed))
|
||
self.navigationItem.setLeftBarButton(cancelItem, animated: false)
|
||
self.navigationPresentation = .modal
|
||
} else {
|
||
self.navigationPresentation = .modalInLargeLayout
|
||
}
|
||
|
||
updateInProgressImpl = { [weak self] inProgress in
|
||
if let strongSelf = self {
|
||
strongSelf.navigationItem.leftBarButtonItem?.isEnabled = !inProgress
|
||
strongSelf.view.disablesInteractiveTransitionGestureRecognizer = inProgress
|
||
strongSelf.view.disablesInteractiveModalDismiss = inProgress
|
||
}
|
||
}
|
||
|
||
presentImpl = { [weak self] c in
|
||
self?.present(c, in: .window(.root))
|
||
}
|
||
|
||
pushImpl = { [weak self] c in
|
||
self?.push(c)
|
||
}
|
||
|
||
completionImpl = { [weak self] in
|
||
if let strongSelf = self {
|
||
strongSelf.view.addSubview(ConfettiView(frame: strongSelf.view.bounds))
|
||
}
|
||
}
|
||
}
|
||
|
||
required public init(coder aDecoder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
@objc private func cancelPressed() {
|
||
self.dismiss()
|
||
self.wasDismissed?()
|
||
}
|
||
|
||
public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
|
||
super.containerLayoutUpdated(layout, transition: transition)
|
||
|
||
if !self.didSetReady {
|
||
if let view = self.node.hostView.findTaggedView(tag: PremiumStarComponent.View.Tag()) as? PremiumStarComponent.View {
|
||
self.didSetReady = true
|
||
self._ready.set(view.ready)
|
||
|
||
if let sourceView = self.sourceView {
|
||
view.animateFrom = sourceView
|
||
view.containerView = self.containerView
|
||
view.animationColor = self.animationColor
|
||
|
||
self.sourceView = nil
|
||
self.containerView = nil
|
||
self.animationColor = nil
|
||
}
|
||
} else if let view = self.node.hostView.findTaggedView(tag: EmojiHeaderComponent.View.Tag()) as? EmojiHeaderComponent.View {
|
||
self.didSetReady = true
|
||
self._ready.set(view.ready)
|
||
|
||
if let sourceView = self.sourceView {
|
||
view.animateFrom = sourceView
|
||
view.containerView = self.containerView
|
||
|
||
view.animateIn()
|
||
|
||
self.sourceView = nil
|
||
self.containerView = nil
|
||
self.animationColor = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|