mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1146 lines
50 KiB
Swift
1146 lines
50 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 BundleIconComponent
|
|
import SolidRoundedButtonComponent
|
|
import BlurredBackgroundComponent
|
|
import Markdown
|
|
import InAppPurchaseManager
|
|
import ConfettiEffect
|
|
import TextFormat
|
|
import UniversalMediaPlayer
|
|
import AttachmentUI
|
|
|
|
public enum PremiumGiftSource: Equatable {
|
|
case profile
|
|
case attachMenu
|
|
|
|
var identifier: String? {
|
|
switch self {
|
|
case .profile:
|
|
return "profile"
|
|
case .attachMenu:
|
|
return "attach"
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class PremiumGiftScreenContentComponent: CombinedComponent {
|
|
typealias EnvironmentType = (ViewControllerComponentContainer.Environment, ScrollChildEnvironment)
|
|
|
|
let context: AccountContext
|
|
let source: PremiumGiftSource
|
|
let peer: EnginePeer?
|
|
let products: [PremiumGiftProduct]?
|
|
let selectedProductId: String?
|
|
|
|
let present: (ViewController) -> Void
|
|
let selectProduct: (String) -> Void
|
|
let buy: () -> Void
|
|
|
|
init(context: AccountContext, source: PremiumGiftSource, peer: EnginePeer?, products: [PremiumGiftProduct]?, selectedProductId: String?, present: @escaping (ViewController) -> Void, selectProduct: @escaping (String) -> Void, buy: @escaping () -> Void) {
|
|
self.context = context
|
|
self.source = source
|
|
self.peer = peer
|
|
self.products = products
|
|
self.selectedProductId = selectedProductId
|
|
self.present = present
|
|
self.selectProduct = selectProduct
|
|
self.buy = buy
|
|
}
|
|
|
|
static func ==(lhs: PremiumGiftScreenContentComponent, rhs: PremiumGiftScreenContentComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.source != rhs.source {
|
|
return false
|
|
}
|
|
if lhs.peer != rhs.peer {
|
|
return false
|
|
}
|
|
if lhs.products != rhs.products {
|
|
return false
|
|
}
|
|
if lhs.selectedProductId != rhs.selectedProductId {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
final class State: ComponentState {
|
|
private let context: AccountContext
|
|
|
|
private var disposable: Disposable?
|
|
private(set) var configuration = PremiumIntroConfiguration.defaultValue
|
|
private(set) var promoConfiguration: PremiumPromoConfiguration?
|
|
|
|
private var stickersDisposable: Disposable?
|
|
private var preloadDisposableSet = DisposableSet()
|
|
|
|
var price: String?
|
|
|
|
init(context: AccountContext, source: PremiumGiftSource) {
|
|
self.context = context
|
|
|
|
super.init()
|
|
|
|
self.disposable = (context.engine.data.subscribe(
|
|
TelegramEngine.EngineData.Item.Configuration.App(),
|
|
TelegramEngine.EngineData.Item.Configuration.PremiumPromo()
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] appConfiguration, promoConfiguration in
|
|
if let strongSelf = self {
|
|
strongSelf.configuration = PremiumIntroConfiguration.with(appConfiguration: appConfiguration)
|
|
strongSelf.promoConfiguration = promoConfiguration
|
|
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_gift.promo_screen_show", data: json)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
})
|
|
|
|
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 text = Child(MultilineTextComponent.self)
|
|
let optionsSection = Child(SectionGroupComponent.self)
|
|
let perksSection = Child(SectionGroupComponent.self)
|
|
|
|
return { context in
|
|
let sideInset: CGFloat = 16.0
|
|
|
|
let component = context.component
|
|
|
|
let scrollEnvironment = context.environment[ScrollChildEnvironment.self].value
|
|
let environment = context.environment[ViewControllerComponentContainer.Environment.self].value
|
|
|
|
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)
|
|
|
|
let overscroll = overscroll.update(
|
|
component: Rectangle(color: theme.list.plainBackgroundColor),
|
|
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))
|
|
)
|
|
|
|
size.height += 183.0 + 10.0 + environment.navigationHeight - 56.0
|
|
|
|
let textColor = theme.list.itemPrimaryTextColor
|
|
let titleColor = theme.list.itemPrimaryTextColor
|
|
let subtitleColor = theme.list.itemSecondaryTextColor
|
|
let arrowColor = theme.list.disclosureArrowColor
|
|
let accentColor = theme.list.itemAccentColor
|
|
|
|
let textFont = Font.regular(15.0)
|
|
let boldTextFont = Font.semibold(15.0)
|
|
|
|
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: textColor), linkAttribute: { _ in
|
|
return nil
|
|
})
|
|
let text = text.update(
|
|
component: MultilineTextComponent(
|
|
text: .markdown(
|
|
text: strings.Premium_Gift_Description(component.peer?.compactDisplayTitle ?? "").string,
|
|
attributes: markdownAttributes
|
|
),
|
|
horizontalAlignment: .center,
|
|
maximumNumberOfLines: 0,
|
|
lineSpacing: 0.2
|
|
),
|
|
environment: {},
|
|
availableSize: CGSize(width: availableWidth - sideInsets, height: 240.0),
|
|
transition: context.transition
|
|
)
|
|
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
|
|
|
|
var items: [SectionGroupComponent.Item] = []
|
|
|
|
var i = 0
|
|
if let products = component.products {
|
|
let gradientColors: [UIColor] = [
|
|
UIColor(rgb: 0x8e77ff),
|
|
UIColor(rgb: 0x9a6fff),
|
|
UIColor(rgb: 0xb36eee)
|
|
]
|
|
|
|
let shortestOptionPrice: (Int64, NSDecimalNumber)
|
|
if let product = products.last {
|
|
shortestOptionPrice = (Int64(Float(product.storeProduct.priceCurrencyAndAmount.amount) / Float(product.months)), product.storeProduct.priceValue.dividing(by: NSDecimalNumber(value: product.months)))
|
|
} else {
|
|
shortestOptionPrice = (1, NSDecimalNumber(decimal: 1))
|
|
}
|
|
|
|
for product in products {
|
|
let giftTitle: String
|
|
if product.months == 12 {
|
|
giftTitle = strings.Premium_Gift_Years(1)
|
|
} else {
|
|
giftTitle = strings.Premium_Gift_Months(product.months)
|
|
}
|
|
|
|
let discountValue = Int((1.0 - Float(product.storeProduct.priceCurrencyAndAmount.amount) / Float(product.months) / Float(shortestOptionPrice.0)) * 100.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.storeProduct.pricePerMonth(Int(product.months))
|
|
pricePerMonth = environment.strings.Premium_PricePerMonth(pricePerMonth).string
|
|
|
|
if discountValue > 0 {
|
|
subtitle = "**\(defaultPrice)** \(product.price)"
|
|
accessibilitySubtitle = product.price
|
|
}
|
|
|
|
items.append(SectionGroupComponent.Item(
|
|
AnyComponentWithIdentity(
|
|
id: product.id,
|
|
component: AnyComponent(
|
|
PremiumOptionComponent(
|
|
title: giftTitle,
|
|
subtitle: subtitle,
|
|
labelPrice: pricePerMonth,
|
|
discount: discount,
|
|
selected: product.id == component.selectedProductId,
|
|
primaryTextColor: textColor,
|
|
secondaryTextColor: subtitleColor,
|
|
accentColor: gradientColors[i],
|
|
checkForegroundColor: environment.theme.list.itemCheckColors.foregroundColor,
|
|
checkBorderColor: environment.theme.list.itemCheckColors.strokeColor
|
|
)
|
|
)
|
|
),
|
|
accessibilityLabel: "\(giftTitle). \(accessibilitySubtitle). \(pricePerMonth)",
|
|
action: {
|
|
component.selectProduct(product.id)
|
|
})
|
|
)
|
|
i += 1
|
|
}
|
|
}
|
|
|
|
let optionsSection = optionsSection.update(
|
|
component: SectionGroupComponent(
|
|
items: items,
|
|
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
|
|
size.height += 23.0
|
|
|
|
let state = context.state
|
|
let accountContext = context.component.context
|
|
let present = context.component.present
|
|
let buy = context.component.buy
|
|
|
|
let price = context.component.products?.first(where: { $0.id == context.component.selectedProductId })?.price
|
|
state.price = price
|
|
|
|
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)
|
|
]
|
|
|
|
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
|
|
)
|
|
)
|
|
),
|
|
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 buttonText: String
|
|
if let price = state?.price {
|
|
buttonText = strings.Premium_Gift_GiftSubscription(price).string
|
|
} else {
|
|
buttonText = strings.Common_OK
|
|
}
|
|
var dismissImpl: (() -> Void)?
|
|
let controller = PremiumLimitsListScreen(context: accountContext, subject: demoSubject, source: .gift(state?.price), order: state?.configuration.perks, buttonText: buttonText, isPremium: false)
|
|
controller.action = { [weak state] in
|
|
dismissImpl?()
|
|
if let _ = state?.price {
|
|
buy()
|
|
}
|
|
}
|
|
controller.disposed = {
|
|
// updateIsFocused(false)
|
|
}
|
|
present(controller)
|
|
dismissImpl = { [weak controller] in
|
|
controller?.dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
addAppLogEvent(postbox: accountContext.account.postbox, type: "premium_gift.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
|
|
|
|
size.height += 10.0
|
|
size.height += scrollEnvironment.insets.bottom
|
|
|
|
return size
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PremiumGiftProduct: Equatable {
|
|
let giftOption: CachedPremiumGiftOption
|
|
let storeProduct: InAppPurchaseManager.Product
|
|
|
|
var id: String {
|
|
return self.storeProduct.id
|
|
}
|
|
|
|
var months: Int32 {
|
|
return self.giftOption.months
|
|
}
|
|
|
|
var price: String {
|
|
return self.storeProduct.price
|
|
}
|
|
|
|
var pricePerMonth: String {
|
|
return self.storeProduct.pricePerMonth(Int(self.months))
|
|
}
|
|
}
|
|
|
|
private final class PremiumGiftScreenComponent: CombinedComponent {
|
|
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
|
|
|
let context: AccountContext
|
|
let peerId: PeerId
|
|
let options: [CachedPremiumGiftOption]
|
|
let source: PremiumGiftSource
|
|
let buttonStatePromise: Promise<AttachmentMainButtonState?>
|
|
let buttonAction: ActionSlot<Void>
|
|
let updateInProgress: (Bool) -> Void
|
|
let updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void
|
|
let present: (ViewController) -> Void
|
|
let push: (ViewController) -> Void
|
|
let completion: (Int32) -> Void
|
|
|
|
init(
|
|
context: AccountContext,
|
|
peerId: PeerId,
|
|
options: [CachedPremiumGiftOption],
|
|
source: PremiumGiftSource,
|
|
buttonStatePromise: Promise<AttachmentMainButtonState?>,
|
|
buttonAction: ActionSlot<Void>,
|
|
updateInProgress: @escaping (Bool) -> Void,
|
|
updateTabBarAlpha: @escaping (CGFloat, ContainedViewLayoutTransition) -> Void,
|
|
present: @escaping (ViewController) -> Void,
|
|
push: @escaping (ViewController) -> Void,
|
|
completion: @escaping (Int32) -> Void)
|
|
{
|
|
self.context = context
|
|
self.peerId = peerId
|
|
self.options = options
|
|
self.source = source
|
|
self.buttonStatePromise = buttonStatePromise
|
|
self.buttonAction = buttonAction
|
|
self.updateInProgress = updateInProgress
|
|
self.updateTabBarAlpha = updateTabBarAlpha
|
|
self.present = present
|
|
self.push = push
|
|
self.completion = completion
|
|
}
|
|
|
|
static func ==(lhs: PremiumGiftScreenComponent, rhs: PremiumGiftScreenComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.peerId != rhs.peerId {
|
|
return false
|
|
}
|
|
if lhs.options != rhs.options {
|
|
return false
|
|
}
|
|
if lhs.source != rhs.source {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class State: ComponentState {
|
|
private let context: AccountContext
|
|
private let peerId: PeerId
|
|
private let options: [CachedPremiumGiftOption]
|
|
private let source: PremiumGiftSource
|
|
private let buttonStatePromise: Promise<AttachmentMainButtonState?>
|
|
private let buttonAction: ActionSlot<Void>
|
|
private let updateInProgress: (Bool) -> Void
|
|
private let present: (ViewController) -> Void
|
|
private let completion: (Int32) -> Void
|
|
|
|
var topContentOffset: CGFloat?
|
|
var bottomContentOffset: CGFloat?
|
|
|
|
var hasIdleAnimations = true
|
|
|
|
var inProgress = false {
|
|
didSet {
|
|
self.updateButton()
|
|
}
|
|
}
|
|
|
|
var peer: EnginePeer?
|
|
var products: [PremiumGiftProduct]?
|
|
var selectedProductId: String?
|
|
|
|
private var disposable: Disposable?
|
|
private var paymentDisposable = MetaDisposable()
|
|
private var activationDisposable = MetaDisposable()
|
|
|
|
init(
|
|
context: AccountContext,
|
|
peerId: PeerId,
|
|
options: [CachedPremiumGiftOption],
|
|
source: PremiumGiftSource,
|
|
buttonStatePromise: Promise<AttachmentMainButtonState?>,
|
|
buttonAction: ActionSlot<Void>,
|
|
updateInProgress: @escaping (Bool) -> Void,
|
|
present: @escaping (ViewController) -> Void,
|
|
completion: @escaping (Int32) -> Void)
|
|
{
|
|
self.context = context
|
|
self.peerId = peerId
|
|
self.options = options
|
|
self.source = source
|
|
self.buttonAction = buttonAction
|
|
self.buttonStatePromise = buttonStatePromise
|
|
self.updateInProgress = updateInProgress
|
|
self.present = present
|
|
self.completion = completion
|
|
|
|
super.init()
|
|
|
|
let availableProducts: Signal<[InAppPurchaseManager.Product], NoError>
|
|
if let inAppPurchaseManager = context.inAppPurchaseManager {
|
|
availableProducts = inAppPurchaseManager.availableProducts
|
|
} else {
|
|
availableProducts = .single([])
|
|
}
|
|
|
|
self.disposable = combineLatest(
|
|
queue: Queue.mainQueue(),
|
|
availableProducts,
|
|
context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|
|
).start(next: { [weak self] products, peer in
|
|
if let strongSelf = self {
|
|
var gifts: [PremiumGiftProduct] = []
|
|
for option in strongSelf.options {
|
|
if let product = products.first(where: { $0.id == option.storeProductId }), !product.isSubscription {
|
|
gifts.append(PremiumGiftProduct(giftOption: option, storeProduct: product))
|
|
}
|
|
}
|
|
|
|
strongSelf.products = gifts
|
|
if strongSelf.selectedProductId == nil && strongSelf.source != .attachMenu {
|
|
strongSelf.selectedProductId = strongSelf.products?.first?.id
|
|
}
|
|
strongSelf.peer = peer
|
|
strongSelf.updated(transition: .immediate)
|
|
}
|
|
})
|
|
|
|
self.buttonAction.connect({ [weak self] in
|
|
self?.buy()
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
self.paymentDisposable.dispose()
|
|
self.activationDisposable.dispose()
|
|
}
|
|
|
|
func selectProduct(id: String) {
|
|
self.selectedProductId = id
|
|
self.updateButton()
|
|
|
|
self.updated(transition: .immediate)
|
|
}
|
|
|
|
private func updateButton() {
|
|
guard self.source == .attachMenu else {
|
|
return
|
|
}
|
|
|
|
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
|
let price: String?
|
|
if let products = self.products, let selectedProductId = self.selectedProductId, let product = products.first(where: { $0.id == selectedProductId }) {
|
|
price = product.price
|
|
} else {
|
|
price = nil
|
|
}
|
|
let buttonText = presentationData.strings.Premium_Gift_GiftSubscription(price ?? "—").string
|
|
self.buttonStatePromise.set(.single(AttachmentMainButtonState(text: buttonText, font: .bold, background: .premium, textColor: .white, isVisible: true, progress: self.inProgress ? .center : .none, isEnabled: true)))
|
|
}
|
|
|
|
func buy() {
|
|
guard let inAppPurchaseManager = self.context.inAppPurchaseManager, !self.inProgress else {
|
|
return
|
|
}
|
|
|
|
guard let product = self.products?.first(where: { $0.id == self.selectedProductId }) else {
|
|
return
|
|
}
|
|
let (currency, amount) = product.storeProduct.priceCurrencyAndAmount
|
|
let duration = product.months
|
|
|
|
addAppLogEvent(postbox: self.context.account.postbox, type: "premium_gift.promo_screen_accept")
|
|
|
|
self.inProgress = true
|
|
self.updateInProgress(true)
|
|
self.updated(transition: .immediate)
|
|
|
|
let _ = (self.context.engine.payments.canPurchasePremium(purpose: .gift(peerId: self.peerId, currency: currency, amount: amount))
|
|
|> deliverOnMainQueue).start(next: { [weak self] available in
|
|
if let strongSelf = self {
|
|
if available {
|
|
strongSelf.paymentDisposable.set((inAppPurchaseManager.buyProduct(product.storeProduct, targetPeerId: strongSelf.peerId)
|
|
|> deliverOnMainQueue).start(next: { [weak self] status in
|
|
if let strongSelf = self, case .purchased = status {
|
|
Queue.mainQueue().after(2.0) {
|
|
let _ = updatePremiumPromoConfigurationOnce(account: strongSelf.context.account).start()
|
|
strongSelf.inProgress = false
|
|
strongSelf.updateInProgress(false)
|
|
|
|
strongSelf.updated(transition: .easeInOut(duration: 0.25))
|
|
strongSelf.completion(duration)
|
|
}
|
|
}
|
|
}, error: { [weak self] error in
|
|
if let strongSelf = self {
|
|
strongSelf.inProgress = false
|
|
strongSelf.updateInProgress(false)
|
|
strongSelf.updated(transition: .immediate)
|
|
|
|
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
|
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_gift.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 makeState() -> State {
|
|
return State(
|
|
context: self.context,
|
|
peerId: self.peerId,
|
|
options: self.options,
|
|
source: self.source,
|
|
buttonStatePromise: self.buttonStatePromise,
|
|
buttonAction: self.buttonAction,
|
|
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(GiftAvatarComponent.self)
|
|
let topPanel = Child(BlurredBackgroundComponent.self)
|
|
let topSeparator = Child(Rectangle.self)
|
|
let title = Child(MultilineTextComponent.self)
|
|
let secondaryTitle = Child(MultilineTextComponent.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
|
|
}
|
|
|
|
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 title = title.update(
|
|
component: MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: environment.strings.Premium_Gift_Title, font: Font.bold(28.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)),
|
|
horizontalAlignment: .center,
|
|
truncationType: .end,
|
|
maximumNumberOfLines: 1
|
|
),
|
|
availableSize: context.availableSize,
|
|
transition: context.transition
|
|
)
|
|
|
|
let secondaryTitle = secondaryTitle.update(
|
|
component: MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: environment.strings.Premium_Gift_TitleShort, font: Font.bold(28.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)),
|
|
horizontalAlignment: .center,
|
|
truncationType: .end,
|
|
maximumNumberOfLines: 1
|
|
),
|
|
availableSize: context.availableSize,
|
|
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 = context.component.source == .attachMenu ? environment.safeInsets.bottom : bottomPanelPadding + 50.0 + bottomInset
|
|
|
|
let topInset: CGFloat = environment.navigationHeight - 56.0
|
|
|
|
context.add(background
|
|
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
|
|
)
|
|
|
|
let scrollContent = scrollContent.update(
|
|
component: ScrollComponent<EnvironmentType>(
|
|
content: AnyComponent(PremiumGiftScreenContentComponent(
|
|
context: context.component.context,
|
|
source: context.component.source,
|
|
peer: state.peer,
|
|
products: state.products,
|
|
selectedProductId: state.selectedProductId,
|
|
present: context.component.present,
|
|
selectProduct: { [weak state] productId in
|
|
state?.selectProduct(id: productId)
|
|
}, buy: { [weak state] in
|
|
state?.buy()
|
|
}
|
|
)),
|
|
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
|
|
)
|
|
|
|
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
|
|
|
|
titleAlpha = 1.0 - min(1.0, fraction * 1.1)
|
|
} else {
|
|
topPanelAlpha = 0.0
|
|
titleScale = 1.0
|
|
titleOffset = 0.0
|
|
titleAlpha = 1.0
|
|
}
|
|
|
|
let star = star.update(
|
|
component: GiftAvatarComponent(
|
|
context: context.component.context,
|
|
peer: context.state.peer,
|
|
isVisible: starIsVisible,
|
|
hasIdleAnimations: state.hasIdleAnimations
|
|
),
|
|
availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0),
|
|
transition: context.transition
|
|
)
|
|
|
|
context.add(star
|
|
.position(CGPoint(x: context.availableSize.width / 2.0, y: topInset + star.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))
|
|
)
|
|
|
|
let price: String?
|
|
if let products = state.products, let selectedProductId = state.selectedProductId, let product = products.first(where: { $0.id == selectedProductId }) {
|
|
price = product.price
|
|
} else {
|
|
price = nil
|
|
}
|
|
|
|
let bottomPanelAlpha: CGFloat
|
|
if let bottomContentOffset = state.bottomContentOffset {
|
|
bottomPanelAlpha = min(16.0, bottomContentOffset) / 16.0
|
|
} else {
|
|
bottomPanelAlpha = 1.0
|
|
}
|
|
|
|
if context.component.source == .attachMenu {
|
|
context.component.updateTabBarAlpha(bottomPanelAlpha, .immediate)
|
|
} else {
|
|
let sideInset: CGFloat = 16.0
|
|
let button = button.update(
|
|
component: SolidRoundedButtonComponent(
|
|
title: environment.strings.Premium_Gift_GiftSubscription(price ?? "—").string,
|
|
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
|
|
)
|
|
|
|
context.add(bottomPanel
|
|
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height / 2.0))
|
|
.opacity(bottomPanelAlpha)
|
|
)
|
|
context.add(bottomSeparator
|
|
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height))
|
|
.opacity(bottomPanelAlpha)
|
|
)
|
|
context.add(button
|
|
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - bottomPanel.size.height + bottomPanelPadding + button.size.height / 2.0))
|
|
)
|
|
}
|
|
|
|
return context.availableSize
|
|
}
|
|
}
|
|
}
|
|
|
|
public final class PremiumGiftScreen: ViewControllerComponentContainer, AttachmentContainable {
|
|
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?
|
|
|
|
fileprivate let mainButtonStatePromise = Promise<AttachmentMainButtonState?>(nil)
|
|
private let mainButtonActionSlot = ActionSlot<Void>()
|
|
|
|
public init(context: AccountContext, peerId: PeerId, options: [CachedPremiumGiftOption], source: PremiumGiftSource, pushController: @escaping (ViewController) -> Void, completion: @escaping () -> Void) {
|
|
self.context = context
|
|
|
|
var updateInProgressImpl: ((Bool) -> Void)?
|
|
var presentImpl: ((ViewController) -> Void)?
|
|
var pushImpl: ((ViewController) -> Void)?
|
|
var completionImpl: ((Int32) -> Void)?
|
|
var updateTabBarAlphaImpl: ((CGFloat, ContainedViewLayoutTransition) -> Void)?
|
|
super.init(context: context, component: PremiumGiftScreenComponent(
|
|
context: context,
|
|
peerId: peerId,
|
|
options: options,
|
|
source: source,
|
|
buttonStatePromise: self.mainButtonStatePromise,
|
|
buttonAction: self.mainButtonActionSlot,
|
|
updateInProgress: { inProgress in
|
|
updateInProgressImpl?(inProgress)
|
|
},
|
|
updateTabBarAlpha: { alpha, transition in
|
|
updateTabBarAlphaImpl?(alpha, transition)
|
|
},
|
|
present: { c in
|
|
presentImpl?(c)
|
|
},
|
|
push: { c in
|
|
pushImpl?(c)
|
|
},
|
|
completion: { duration in
|
|
completionImpl?(duration)
|
|
}
|
|
), navigationBarAppearance: .transparent, presentationMode: .modal)
|
|
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
|
|
let cancelItem = UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
|
|
self.navigationItem.setLeftBarButton(cancelItem, animated: false)
|
|
self.navigationPresentation = .modal
|
|
|
|
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 = { c in
|
|
pushController(c)
|
|
}
|
|
|
|
completionImpl = { _ in
|
|
completion()
|
|
}
|
|
|
|
updateTabBarAlphaImpl = { [weak self] alpha, transition in
|
|
self?.updateTabBarAlpha(alpha, transition)
|
|
}
|
|
}
|
|
|
|
required public init(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
@objc private func cancelPressed() {
|
|
self.dismiss()
|
|
}
|
|
|
|
public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
|
|
super.containerLayoutUpdated(layout, transition: transition)
|
|
|
|
if !self.didSetReady {
|
|
self.didSetReady = true
|
|
if let view = self.node.hostView.findTaggedView(tag: GiftAvatarComponent.View.Tag()) as? GiftAvatarComponent.View {
|
|
self._ready.set(view.ready)
|
|
} else {
|
|
self._ready.set(.single(true))
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc fileprivate func mainButtonPressed() {
|
|
self.mainButtonActionSlot.invoke(Void())
|
|
}
|
|
|
|
public var requestAttachmentMenuExpansion: () -> Void = {}
|
|
public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in }
|
|
public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in }
|
|
public var cancelPanGesture: () -> Void = { }
|
|
public var isContainerPanning: () -> Bool = { return false }
|
|
public var isContainerExpanded: () -> Bool = { return false }
|
|
|
|
public var mediaPickerContext: AttachmentMediaPickerContext? {
|
|
return PremiumGiftContext(controller: self)
|
|
}
|
|
}
|
|
|
|
private final class PremiumGiftContext: AttachmentMediaPickerContext {
|
|
private weak var controller: PremiumGiftScreen?
|
|
|
|
var selectionCount: Signal<Int, NoError> {
|
|
return .single(0)
|
|
}
|
|
|
|
var caption: Signal<NSAttributedString?, NoError> {
|
|
return .single(nil)
|
|
}
|
|
|
|
public var loadingProgress: Signal<CGFloat?, NoError> {
|
|
return .single(nil)
|
|
}
|
|
|
|
public var mainButtonState: Signal<AttachmentMainButtonState?, NoError> {
|
|
return self.controller?.mainButtonStatePromise.get() ?? .single(nil)
|
|
}
|
|
|
|
init(controller: PremiumGiftScreen) {
|
|
self.controller = controller
|
|
}
|
|
|
|
func setCaption(_ caption: NSAttributedString) {
|
|
}
|
|
|
|
func send(mode: AttachmentMediaPickerSendMode, attachmentMode: AttachmentMediaPickerAttachmentMode) {
|
|
}
|
|
|
|
func schedule() {
|
|
}
|
|
|
|
func mainButtonAction() {
|
|
self.controller?.mainButtonPressed()
|
|
}
|
|
}
|