import Foundation import UIKit import Display import ComponentFlow import SwiftSignalKit import TelegramCore import Postbox import TelegramPresentationData import PresentationDataUtils import ViewControllerComponent import AccountContext import MultilineTextComponent import BalancedTextComponent import Markdown import InAppPurchaseManager import AnimationCache import MultiAnimationRenderer import UndoUI import TelegramStringFormatting import ListSectionComponent import ListActionItemComponent import ScrollComponent import BlurredBackgroundComponent import TextFormat import PremiumStarComponent import BundleIconComponent import ConfettiEffect private struct StarsProduct: Equatable { enum Option: Equatable { case topUp(StarsTopUpOption) case gift(StarsGiftOption) } let option: Option let storeProduct: InAppPurchaseManager.Product var count: Int64 { switch self.option { case let .topUp(option): return option.count case let .gift(option): return option.count } } var isExtended: Bool { switch self.option { case let .topUp(option): return option.isExtended case let .gift(option): return option.isExtended } } var id: String { return self.storeProduct.id } var price: String { return self.storeProduct.price } } private final class StarsPurchaseScreenContentComponent: CombinedComponent { typealias EnvironmentType = (ViewControllerComponentContainer.Environment, ScrollChildEnvironment) public class ExternalState { public var descriptionHeight: CGFloat = 0.0 public init() { } } let context: AccountContext let externalState: ExternalState let containerSize: CGSize let balance: Int64? let options: [Any] let purpose: StarsPurchasePurpose let selectedProductId: String? let forceDark: Bool let products: [StarsProduct]? let expanded: Bool let peers: [EnginePeer.Id: EnginePeer] let stateUpdated: (ComponentTransition) -> Void let buy: (StarsProduct) -> Void init( context: AccountContext, externalState: ExternalState, containerSize: CGSize, balance: Int64?, options: [Any], purpose: StarsPurchasePurpose, selectedProductId: String?, forceDark: Bool, products: [StarsProduct]?, expanded: Bool, peers: [EnginePeer.Id: EnginePeer], stateUpdated: @escaping (ComponentTransition) -> Void, buy: @escaping (StarsProduct) -> Void ) { self.context = context self.externalState = externalState self.containerSize = containerSize self.balance = balance self.options = options self.purpose = purpose self.selectedProductId = selectedProductId self.forceDark = forceDark self.products = products self.expanded = expanded self.peers = peers self.stateUpdated = stateUpdated self.buy = buy } static func ==(lhs: StarsPurchaseScreenContentComponent, rhs: StarsPurchaseScreenContentComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.containerSize != rhs.containerSize { return false } if lhs.purpose != rhs.purpose { return false } if lhs.selectedProductId != rhs.selectedProductId { return false } if lhs.forceDark != rhs.forceDark { return false } if lhs.products != rhs.products { return false } if lhs.expanded != rhs.expanded { return false } if lhs.peers != rhs.peers { return false } return true } final class State: ComponentState { private let context: AccountContext var products: [StarsProduct]? private var disposable: Disposable? var cachedChevronImage: (UIImage, PresentationTheme)? init( context: AccountContext, purpose: StarsPurchasePurpose ) { self.context = context super.init() } deinit { self.disposable?.dispose() } } func makeState() -> State { return State(context: self.context, purpose: self.purpose) } static var body: Body { let text = Child(BalancedTextComponent.self) let list = Child(VStack.self) let termsText = Child(BalancedTextComponent.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 state = context.state state.products = component.products let theme = environment.theme let strings = environment.strings let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } 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) size.height += 183.0 + 10.0 + environment.navigationHeight - 56.0 let textColor = theme.list.itemPrimaryTextColor let accentColor = theme.list.itemAccentColor let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) let textString: String switch context.component.purpose { case .generic: textString = strings.Stars_Purchase_GetStarsInfo case .gift: textString = strings.Stars_Purchase_GiftInfo(component.peers.first?.value.compactDisplayTitle ?? "").string case .transfer: textString = strings.Stars_Purchase_StarsNeededInfo(component.peers.first?.value.compactDisplayTitle ?? "").string case let .subscription(_, _, renew): textString = renew ? strings.Stars_Purchase_SubscriptionRenewInfo(component.peers.first?.value.compactDisplayTitle ?? "").string : strings.Stars_Purchase_SubscriptionInfo(component.peers.first?.value.compactDisplayTitle ?? "").string case .unlockMedia: textString = strings.Stars_Purchase_StarsNeededUnlockInfo } let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: accentColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== theme { state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: accentColor)!, theme) } let titleAttributedString = parseMarkdownIntoAttributedString(textString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString if let range = titleAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { titleAttributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: titleAttributedString.string)) } let text = text.update( component: BalancedTextComponent( text: .plain(titleAttributedString), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2, 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: { _, _ in } ), 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)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) size.height += text.size.height size.height += 21.0 context.component.externalState.descriptionHeight = text.size.height let stars: [Int64: Int] = [ 15: 1, 75: 2, 250: 3, 500: 4, 1000: 5, 2500: 6, 25: 1, 50: 1, 100: 2, 150: 2, 350: 3, 750: 4, 1500: 5, 5000: 6, 10000: 6, 25000: 7, 35000: 7 ] let externalStateUpdated = context.component.stateUpdated size.height += 8.0 var i = 0 var items: [AnyComponentWithIdentity] = [] if let products = state.products, let balance = context.component.balance { var minimumCount: Int64? if let requiredStars = context.component.purpose.requiredStars { minimumCount = requiredStars - balance } for product in products { if let minimumCount, minimumCount > product.count && !(items.isEmpty && product.id == products.last?.id) { continue } if let _ = minimumCount, items.isEmpty { } else if !context.component.expanded && product.isExtended { continue } let title = strings.Stars_Purchase_Stars(Int32(product.count)) let price = product.price let titleComponent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: title, font: Font.medium(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 0 )) let backgroundComponent: AnyComponent? if product.storeProduct.id == context.component.selectedProductId { backgroundComponent = AnyComponent( ItemLoadingComponent(color: environment.theme.list.itemAccentColor) ) } else { backgroundComponent = nil } let buy = context.component.buy items.append(AnyComponentWithIdentity( id: product.id, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: nil, items: [AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: environment.theme, background: backgroundComponent, title: titleComponent, contentInsets: UIEdgeInsets(top: 12.0, left: -6.0, bottom: 12.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(StarsIconComponent( count: stars[product.count] ?? 1 ))), true), accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: price, font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemSecondaryTextColor )), maximumNumberOfLines: 0 ))), insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 16.0))), action: { _ in buy(product) }, highlighting: .disabled, updateIsHighlighted: { view, isHighlighted in let transition: ComponentTransition = .easeInOut(duration: 0.25) if let superview = view.superview { transition.setScale(view: superview, scale: isHighlighted ? 0.9 : 1.0) } } )))] )) )) i += 1 } } if !context.component.expanded && items.count > 1 { let titleComponent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.Stars_Purchase_ShowMore, font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemAccentColor )), horizontalAlignment: .center, maximumNumberOfLines: 0 )) let titleCombinedComponent = AnyComponent(HStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: titleComponent), AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(BundleIconComponent(name: "Chat/Input/Search/DownButton", tintColor: environment.theme.list.itemAccentColor))) ], spacing: 1.0)) items.append(AnyComponentWithIdentity( id: items.count, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: nil, items: [AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: titleCombinedComponent, titleAlignment: .center, contentInsets: UIEdgeInsets(top: 7.0, left: 0.0, bottom: 7.0, right: 0.0), leftIcon: nil, accessory: .none, action: { _ in externalStateUpdated(.easeInOut(duration: 0.3)) } )))] )) )) } let list = list.update( component: VStack(items, spacing: 16.0), environment: {}, availableSize: CGSize(width: availableWidth - sideInsets, height: .greatestFiniteMagnitude), transition: context.transition ) context.add(list .position(CGPoint(x: availableWidth / 2.0, y: size.height + list.size.height / 2.0)) ) size.height += list.size.height size.height += 23.0 let termsFont = Font.regular(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) }) let textSideInset: CGFloat = 16.0 let termsText = termsText.update( component: BalancedTextComponent( text: .markdown(text: strings.Stars_Purchase_Info, attributes: termsMarkdownAttributes), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2, 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 component.context.sharedContext.openExternalUrl(context: component.context, urlContext: .generic, url: strings.Stars_Purchase_Terms_URL, forceExternal: true, presentationData: presentationData, navigationController: nil, dismissInput: {}) } ), environment: {}, availableSize: CGSize(width: availableWidth - sideInsets - textSideInset * 3.0, height: .greatestFiniteMagnitude), transition: context.transition ) context.add(termsText .position(CGPoint(x: availableWidth / 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.expanded { size.height = max(size.height, component.containerSize.height + 150.0 + text.size.height) } return size } } } private final class StarsPurchaseScreenComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let starsContext: StarsContext let options: [Any] let purpose: StarsPurchasePurpose let forceDark: Bool let updateInProgress: (Bool) -> Void let present: (ViewController) -> Void let completion: (Int64) -> Void init( context: AccountContext, starsContext: StarsContext, options: [Any], purpose: StarsPurchasePurpose, forceDark: Bool, updateInProgress: @escaping (Bool) -> Void, present: @escaping (ViewController) -> Void, completion: @escaping (Int64) -> Void ) { self.context = context self.starsContext = starsContext self.options = options self.purpose = purpose self.forceDark = forceDark self.updateInProgress = updateInProgress self.present = present self.completion = completion } static func ==(lhs: StarsPurchaseScreenComponent, rhs: StarsPurchaseScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.starsContext !== rhs.starsContext { return false } if lhs.purpose != rhs.purpose { return false } if lhs.forceDark != rhs.forceDark { return false } return true } final class State: ComponentState { private let context: AccountContext private let purpose: StarsPurchasePurpose private let updateInProgress: (Bool) -> Void private let present: (ViewController) -> Void private let completion: (Int64) -> Void var topContentOffset: CGFloat? var bottomContentOffset: CGFloat? var hasIdleAnimations = true var progressProduct: StarsProduct? private(set) var products: [StarsProduct]? private(set) var starsState: StarsContext.State? var peers: [EnginePeer.Id: EnginePeer] = [:] let animationCache: AnimationCache let animationRenderer: MultiAnimationRenderer private var disposable: Disposable? private var paymentDisposable = MetaDisposable() init( context: AccountContext, starsContext: StarsContext, purpose: StarsPurchasePurpose, initialOptions: [Any], updateInProgress: @escaping (Bool) -> Void, present: @escaping (ViewController) -> Void, completion: @escaping (Int64) -> Void ) { self.context = context self.purpose = purpose self.updateInProgress = updateInProgress self.present = present self.completion = completion self.animationCache = context.animationCache self.animationRenderer = context.animationRenderer super.init() let availableProducts: Signal<[InAppPurchaseManager.Product], NoError> if let inAppPurchaseManager = context.inAppPurchaseManager { availableProducts = inAppPurchaseManager.availableProducts } else { availableProducts = .single([]) } let products: Signal<[StarsProduct], NoError> switch purpose { case .gift: let options: Signal<[StarsGiftOption], NoError> if !initialOptions.isEmpty, let initialGiftOptions = initialOptions as? [StarsGiftOption] { options = .single(initialGiftOptions) } else { options = .single([]) |> then(context.engine.payments.starsGiftOptions(peerId: nil)) } products = combineLatest(availableProducts, options) |> map { availableProducts, options in var products: [StarsProduct] = [] for option in options { if let product = availableProducts.first(where: { $0.id == option.storeProductId }) { products.append(StarsProduct(option: .gift(option), storeProduct: product)) } } return products } default: let options: Signal<[StarsTopUpOption], NoError> if !initialOptions.isEmpty, let initialTopUpOptions = initialOptions as? [StarsTopUpOption] { options = .single(initialTopUpOptions) } else { options = .single([]) |> then(context.engine.payments.starsTopUpOptions()) } products = combineLatest(availableProducts, options) |> map { availableProducts, options in var products: [StarsProduct] = [] for option in options { if let product = availableProducts.first(where: { $0.id == option.storeProductId }) { products.append(StarsProduct(option: .topUp(option), storeProduct: product)) } } return products } } let peerIds = purpose.peerIds self.disposable = combineLatest( queue: Queue.mainQueue(), products, starsContext.state, context.engine.data.get(EngineDataMap(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)))) ).start(next: { [weak self] products, starsState, result in guard let self else { return } self.products = products.sorted(by: { $0.count < $1.count }) self.starsState = starsState var peers: [EnginePeer.Id: EnginePeer] = [:] for peerId in peerIds { if let maybePeer = result[peerId], let peer = maybePeer { peers[peerId] = peer } } self.peers = peers self.updated(transition: .immediate) }) } deinit { self.disposable?.dispose() self.paymentDisposable.dispose() } func buy(product: StarsProduct) { guard let inAppPurchaseManager = self.context.inAppPurchaseManager, self.progressProduct == nil else { return } self.progressProduct = product self.updateInProgress(true) self.updated(transition: .easeInOut(duration: 0.2)) let (currency, amount) = product.storeProduct.priceCurrencyAndAmount let purpose: AppStoreTransactionPurpose switch self.purpose { case let .gift(peerId): purpose = .starsGift(peerId: peerId, count: product.count, currency: currency, amount: amount) default: purpose = .stars(count: product.count, currency: currency, amount: amount) } let _ = (self.context.engine.payments.canPurchasePremium(purpose: purpose) |> deliverOnMainQueue).start(next: { [weak self] available in if let strongSelf = self { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } if available { strongSelf.paymentDisposable.set((inAppPurchaseManager.buyProduct(product.storeProduct, purpose: purpose) |> deliverOnMainQueue).start(next: { [weak self] status in if let self, case .purchased = status { self.updateInProgress(false) self.updated(transition: .easeInOut(duration: 0.2)) self.completion(product.count) } }, error: { [weak self] error in if let strongSelf = self { strongSelf.progressProduct = nil strongSelf.updateInProgress(false) strongSelf.updated(transition: .easeInOut(duration: 0.2)) 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 .tryLater: errorText = presentationData.strings.Premium_Purchase_ErrorTryLater case .cancelled: break } if let errorText = errorText { 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.progressProduct = nil strongSelf.updateInProgress(false) strongSelf.updated(transition: .easeInOut(duration: 0.2)) } } }) } func updateIsFocused(_ isFocused: Bool) { self.hasIdleAnimations = !isFocused self.updated(transition: .immediate) } var isExpanded = false } func makeState() -> State { return State(context: self.context, starsContext: self.starsContext, purpose: self.purpose, initialOptions: self.options, updateInProgress: self.updateInProgress, present: self.present, completion: self.completion) } static var body: Body { let background = Child(Rectangle.self) let scrollContent = Child(ScrollComponent.self) let star = Child(PremiumStarComponent.self) let avatar = Child(GiftAvatarComponent.self) let topPanel = Child(BlurredBackgroundComponent.self) let topSeparator = Child(Rectangle.self) let title = Child(MultilineTextComponent.self) let balanceTitle = Child(MultilineTextComponent.self) let balanceValue = Child(MultilineTextComponent.self) let balanceIcon = Child(BundleIconComponent.self) let scrollAction = ActionSlot() let contentExternalState = StarsPurchaseScreenContentComponent.ExternalState() return { context in let environment = context.environment[EnvironmentType.self].value let state = context.state let strings = environment.strings 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 header: _UpdatedChildComponent if case let .gift(peerId) = context.component.purpose { var peers: [EnginePeer] = [] if let peer = state.peers[peerId] { peers.append(peer) } header = avatar.update( component: GiftAvatarComponent( context: context.component.context, theme: environment.theme, peers: peers, isVisible: starIsVisible, hasIdleAnimations: state.hasIdleAnimations, color: UIColor(rgb: 0xf9b004), hasLargeParticles: true ), availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0), transition: context.transition ) } else { header = star.update( component: PremiumStarComponent( theme: environment.theme, isIntro: true, isVisible: starIsVisible, hasIdleAnimations: state.hasIdleAnimations, colors: [ UIColor(rgb: 0xe57d02), UIColor(rgb: 0xf09903), UIColor(rgb: 0xf9b004), UIColor(rgb: 0xfdd219) ], particleColor: UIColor(rgb: 0xf9b004) ), 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 titleText: String switch context.component.purpose { case .generic: titleText = strings.Stars_Purchase_GetStars case .gift: titleText = strings.Stars_Purchase_GiftStars case let .transfer(_, requiredStars), let .subscription(_, requiredStars, _), let .unlockMedia(requiredStars): titleText = strings.Stars_Purchase_StarsNeeded(Int32(requiredStars)) } let title = title.update( component: MultilineTextComponent( text: .plain(NSAttributedString(string: titleText, font: Font.bold(28.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), horizontalAlignment: .center, truncationType: .end, maximumNumberOfLines: 1 ), availableSize: context.availableSize, transition: context.transition ) let balanceTitle = balanceTitle.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.Stars_Purchase_Balance, font: Font.regular(14.0), textColor: environment.theme.actionSheet.primaryTextColor )), maximumNumberOfLines: 1 ), availableSize: context.availableSize, transition: .immediate ) let balanceValue = balanceValue.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: presentationStringsFormattedNumber(Int32(state.starsState?.balance ?? 0), environment.dateTimeFormat.groupingSeparator), font: Font.semibold(14.0), textColor: environment.theme.actionSheet.primaryTextColor )), maximumNumberOfLines: 1 ), availableSize: context.availableSize, transition: .immediate ) let balanceIcon = balanceIcon.update( component: BundleIconComponent(name: "Premium/Stars/StarSmall", tintColor: nil), availableSize: context.availableSize, transition: .immediate ) let scrollContent = scrollContent.update( component: ScrollComponent( content: AnyComponent(StarsPurchaseScreenContentComponent( context: context.component.context, externalState: contentExternalState, containerSize: context.availableSize, balance: state.starsState?.balance, options: context.component.options, purpose: context.component.purpose, selectedProductId: state.progressProduct?.storeProduct.id, forceDark: context.component.forceDark, products: state.products, expanded: state.isExpanded, peers: state.peers, stateUpdated: { [weak state] transition in scrollAction.invoke(CGPoint(x: 0.0, y: 150.0 + contentExternalState.descriptionHeight)) state?.isExpanded = true state?.updated(transition: transition) }, buy: { [weak state] product in state?.buy(product: product) } )), contentInsets: UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: environment.safeInsets.bottom, 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 let anchorOffset = 150.0 + contentExternalState.descriptionHeight if targetContentOffset.pointee.y < 100.0 { targetContentOffset.pointee = CGPoint(x: 0.0, y: 0.0) } else if targetContentOffset.pointee.y < anchorOffset { targetContentOffset.pointee = CGPoint(x: 0.0, y: anchorOffset) } }, resetScroll: scrollAction ), 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 titleAlpha = 1.0 } else { topPanelAlpha = 0.0 titleScale = 1.0 titleOffset = 0.0 titleAlpha = 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) ) let navigationHeight = environment.navigationHeight - environment.statusBarHeight let topBalanceOriginY = environment.statusBarHeight + (navigationHeight - balanceTitle.size.height - balanceValue.size.height) / 2.0 context.add(balanceTitle .position(CGPoint(x: context.availableSize.width - 16.0 - environment.safeInsets.right - balanceTitle.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height / 2.0)) ) context.add(balanceValue .position(CGPoint(x: context.availableSize.width - 16.0 - environment.safeInsets.right - balanceValue.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height + balanceValue.size.height / 2.0)) ) context.add(balanceIcon .position(CGPoint(x: context.availableSize.width - 16.0 - environment.safeInsets.right - balanceValue.size.width - balanceIcon.size.width / 2.0 - 2.0, y: topBalanceOriginY + balanceTitle.size.height + balanceValue.size.height / 2.0 - UIScreenPixel)) ) return context.availableSize } } } public final class StarsPurchaseScreen: ViewControllerComponentContainer { fileprivate let context: AccountContext fileprivate let starsContext: StarsContext private var didSetReady = false private let _ready = Promise() public override var ready: Promise { return self._ready } public init( context: AccountContext, starsContext: StarsContext, options: [Any] = [], purpose: StarsPurchasePurpose, completion: @escaping (Int64) -> Void = { _ in } ) { self.context = context self.starsContext = starsContext var updateInProgressImpl: ((Bool) -> Void)? var presentImpl: ((ViewController) -> Void)? var completionImpl: ((Int64) -> Void)? super.init(context: context, component: StarsPurchaseScreenComponent( context: context, starsContext: starsContext, options: options, purpose: purpose, forceDark: false, updateInProgress: { inProgress in updateInProgressImpl?(inProgress) }, present: { c in presentImpl?(c) }, completion: { stars in completionImpl?(stars) } ), navigationBarAppearance: .transparent, presentationMode: .modal, theme: .default) let presentationData = context.sharedContext.currentPresentationData.with { $0 } 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 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 if let self { self.present(c, in: .window(.root)) } } completionImpl = { [weak self] stars in if let self { self.animateSuccess() completion(stars) } } } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.dismissAllTooltips() } fileprivate func dismissAllTooltips() { self.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismiss() } }) self.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismiss() } return true }) } @objc private func cancelPressed() { self.dismiss() self.wasDismissed?() } public func animateSuccess() { self.dismiss() self.navigationController?.view.addSubview(ConfettiView(frame: self.view.bounds, customImage: UIImage(bundleImageName: "Peer Info/PremiumIcon"))) } 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) } else if let view = self.node.hostView.findTaggedView(tag: GiftAvatarComponent.View.Tag()) as? GiftAvatarComponent.View { self.didSetReady = true self._ready.set(view.ready) } } } } func generateStarsIcon(count: Int) -> UIImage { let image = generateGradientTintedImage( image: UIImage(bundleImageName: "Peer Info/PremiumIcon"), colors: [ UIColor(rgb: 0xfed219), UIColor(rgb: 0xf3a103), UIColor(rgb: 0xe78104) ], direction: .diagonal )! let imageSize = CGSize(width: 20.0, height: 20.0) let partImage = generateImage(imageSize, contextGenerator: { size, context in context.clear(CGRect(origin: .zero, size: size)) if let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: .zero, size: size), byTiling: false) context.saveGState() context.clip(to: CGRect(origin: .zero, size: size).insetBy(dx: -1.0, dy: -1.0).offsetBy(dx: -2.0, dy: 0.0), mask: cgImage) context.setBlendMode(.clear) context.setFillColor(UIColor.clear.cgColor) context.fill(CGRect(origin: .zero, size: size)) context.restoreGState() context.setBlendMode(.clear) context.setFillColor(UIColor.clear.cgColor) context.fill(CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width / 2.0, height: size.height - 4.0))) } })! let spacing: CGFloat = (3.0 - UIScreenPixel) let totalWidth = 20.0 + spacing * CGFloat(count - 1) return generateImage(CGSize(width: ceil(totalWidth), height: 20.0), contextGenerator: { size, context in context.clear(CGRect(origin: .zero, size: size)) var originX = floorToScreenPixels((size.width - totalWidth) / 2.0) let mainImage = UIImage(bundleImageName: "Premium/Stars/StarLarge") if let cgImage = mainImage?.cgImage, let partCGImage = partImage.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: originX, y: 0.0), size: imageSize), byTiling: false) originX += spacing + UIScreenPixel for _ in 0 ..< count - 1 { context.draw(partCGImage, in: CGRect(origin: CGPoint(x: originX, y: -UIScreenPixel), size: imageSize).insetBy(dx: -1.0 + UIScreenPixel, dy: -1.0 + UIScreenPixel), byTiling: false) originX += spacing } } })! } final class StarsIconComponent: CombinedComponent { let count: Int init( count: Int ) { self.count = count } static func ==(lhs: StarsIconComponent, rhs: StarsIconComponent) -> Bool { if lhs.count != rhs.count { return false } return true } static var body: Body { let icon = Child(Image.self) var image: (UIImage, Int)? return { context in if image == nil || image?.1 != context.component.count { image = (generateStarsIcon(count: context.component.count), context.component.count) } let iconSize = CGSize(width: image!.0.size.width, height: 20.0) let icon = icon.update( component: Image(image: image?.0), availableSize: iconSize, transition: context.transition ) let iconPosition = CGPoint(x: iconSize.width / 2.0, y: iconSize.height / 2.0) context.add(icon .position(iconPosition) ) return iconSize } } } private extension StarsPurchasePurpose { var peerIds: [EnginePeer.Id] { switch self { case let .gift(peerId): return [peerId] case let .transfer(peerId, _): return [peerId] case let .subscription(peerId, _, _): return [peerId] default: return [] } } var requiredStars: Int64? { switch self { case let .transfer(_, requiredStars): return requiredStars case let .subscription(_, requiredStars, _): return requiredStars case let .unlockMedia(requiredStars): return requiredStars default: return nil } } }