mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1124 lines
47 KiB
Swift
1124 lines
47 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 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 {
|
|
let option: StarsTopUpOption
|
|
let storeProduct: InAppPurchaseManager.Product
|
|
|
|
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 options: [StarsTopUpOption]
|
|
let peerId: EnginePeer.Id?
|
|
let requiredStars: Int64?
|
|
let selectedProductId: String?
|
|
let forceDark: Bool
|
|
let products: [StarsProduct]?
|
|
let expanded: Bool
|
|
let stateUpdated: (Transition) -> Void
|
|
let buy: (StarsProduct) -> Void
|
|
|
|
init(
|
|
context: AccountContext,
|
|
externalState: ExternalState,
|
|
containerSize: CGSize,
|
|
options: [StarsTopUpOption],
|
|
peerId: EnginePeer.Id?,
|
|
requiredStars: Int64?,
|
|
selectedProductId: String?,
|
|
forceDark: Bool,
|
|
products: [StarsProduct]?,
|
|
expanded: Bool,
|
|
stateUpdated: @escaping (Transition) -> Void,
|
|
buy: @escaping (StarsProduct) -> Void
|
|
) {
|
|
self.context = context
|
|
self.externalState = externalState
|
|
self.containerSize = containerSize
|
|
self.options = options
|
|
self.peerId = peerId
|
|
self.requiredStars = requiredStars
|
|
self.selectedProductId = selectedProductId
|
|
self.forceDark = forceDark
|
|
self.products = products
|
|
self.expanded = expanded
|
|
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.options != rhs.options {
|
|
return false
|
|
}
|
|
if lhs.peerId != rhs.peerId {
|
|
return false
|
|
}
|
|
if lhs.requiredStars != rhs.requiredStars {
|
|
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
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class State: ComponentState {
|
|
private let context: AccountContext
|
|
|
|
var products: [StarsProduct]?
|
|
var peer: EnginePeer?
|
|
|
|
private var disposable: Disposable?
|
|
|
|
init(
|
|
context: AccountContext,
|
|
peerId: EnginePeer.Id?
|
|
) {
|
|
self.context = context
|
|
|
|
super.init()
|
|
|
|
if let peerId {
|
|
self.disposable = (context.engine.data.subscribe(
|
|
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] peer in
|
|
if let self, let peer {
|
|
self.peer = peer
|
|
self.updated(transition: .immediate)
|
|
}
|
|
})
|
|
}
|
|
|
|
let _ = updatePremiumPromoConfigurationOnce(account: context.account).start()
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
}
|
|
}
|
|
|
|
func makeState() -> State {
|
|
return State(context: self.context, peerId: self.peerId)
|
|
}
|
|
|
|
static var body: Body {
|
|
let overscroll = Child(Rectangle.self)
|
|
let fade = Child(RoundedRectangle.self)
|
|
let text = Child(BalancedTextComponent.self)
|
|
let list = Child(VStack<Empty>.self)
|
|
let termsText = Child(BalancedTextComponent.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
|
|
|
|
let theme = environment.theme
|
|
let strings = environment.strings
|
|
let presentationData = context.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)
|
|
|
|
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 textFont = Font.regular(15.0)
|
|
let boldTextFont = Font.semibold(15.0)
|
|
|
|
let textString: String
|
|
if let _ = context.component.requiredStars {
|
|
textString = strings.Stars_Purchase_StarsNeededInfo(state.peer?.compactDisplayTitle ?? "").string
|
|
} else {
|
|
textString = strings.Stars_Purchase_GetStarsInfo
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
let text = text.update(
|
|
component: BalancedTextComponent(
|
|
text: .markdown(
|
|
text: textString,
|
|
attributes: markdownAttributes
|
|
),
|
|
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 initialValues: [Int64] = [
|
|
15,
|
|
75,
|
|
250,
|
|
500,
|
|
1000,
|
|
2500
|
|
]
|
|
|
|
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
|
|
]
|
|
|
|
let externalStateUpdated = context.component.stateUpdated
|
|
|
|
size.height += 8.0
|
|
|
|
var i = 0
|
|
var items: [AnyComponentWithIdentity<Empty>] = []
|
|
|
|
if let products = state.products {
|
|
for product in products {
|
|
if let requiredStars = context.component.requiredStars, requiredStars > product.option.count {
|
|
continue
|
|
}
|
|
|
|
if !context.component.expanded && !initialValues.contains(product.option.count) {
|
|
continue
|
|
}
|
|
|
|
let title = strings.Stars_Purchase_Stars(Int32(product.option.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<Empty>?
|
|
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.option.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: Transition = .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 {
|
|
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 component = context.component
|
|
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: [StarsTopUpOption]
|
|
let peerId: EnginePeer.Id?
|
|
let requiredStars: Int64?
|
|
let forceDark: Bool
|
|
let updateInProgress: (Bool) -> Void
|
|
let present: (ViewController) -> Void
|
|
let completion: (Int64) -> Void
|
|
|
|
init(
|
|
context: AccountContext,
|
|
starsContext: StarsContext,
|
|
options: [StarsTopUpOption],
|
|
peerId: EnginePeer.Id?,
|
|
requiredStars: Int64?,
|
|
forceDark: Bool,
|
|
updateInProgress: @escaping (Bool) -> Void,
|
|
present: @escaping (ViewController) -> Void,
|
|
completion: @escaping (Int64) -> Void
|
|
) {
|
|
self.context = context
|
|
self.starsContext = starsContext
|
|
self.options = options
|
|
self.peerId = peerId
|
|
self.requiredStars = requiredStars
|
|
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.options != rhs.options {
|
|
return false
|
|
}
|
|
if lhs.peerId != rhs.peerId {
|
|
return false
|
|
}
|
|
if lhs.requiredStars != rhs.requiredStars {
|
|
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: (Int64) -> Void
|
|
|
|
var topContentOffset: CGFloat?
|
|
var bottomContentOffset: CGFloat?
|
|
|
|
var hasIdleAnimations = true
|
|
|
|
var progressProduct: StarsProduct?
|
|
|
|
private(set) var promoConfiguration: PremiumPromoConfiguration?
|
|
|
|
private(set) var products: [StarsProduct]?
|
|
private(set) var starsState: StarsContext.State?
|
|
|
|
let animationCache: AnimationCache
|
|
let animationRenderer: MultiAnimationRenderer
|
|
|
|
private var disposable: Disposable?
|
|
private var paymentDisposable = MetaDisposable()
|
|
|
|
init(
|
|
context: AccountContext,
|
|
starsContext: StarsContext,
|
|
initialOptions: [StarsTopUpOption],
|
|
updateInProgress: @escaping (Bool) -> Void,
|
|
present: @escaping (ViewController) -> Void,
|
|
completion: @escaping (Int64) -> Void
|
|
) {
|
|
self.context = context
|
|
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 options: Signal<[StarsTopUpOption], NoError>
|
|
if !initialOptions.isEmpty {
|
|
options = .single(initialOptions)
|
|
} else {
|
|
options = .single([]) |> then(context.engine.payments.starsTopUpOptions())
|
|
}
|
|
|
|
self.disposable = combineLatest(
|
|
queue: Queue.mainQueue(),
|
|
availableProducts,
|
|
options,
|
|
starsContext.state
|
|
).start(next: { [weak self] availableProducts, options, starsState in
|
|
guard let self else {
|
|
return
|
|
}
|
|
var products: [StarsProduct] = []
|
|
for option in options {
|
|
if let product = availableProducts.first(where: { $0.id == option.storeProductId }) {
|
|
products.append(StarsProduct(option: option, storeProduct: product))
|
|
}
|
|
}
|
|
|
|
self.products = products.sorted(by: { $0.option.count < $1.option.count })
|
|
self.starsState = starsState
|
|
|
|
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 = .stars(count: product.option.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.option.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 .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, 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<EnvironmentType>.self)
|
|
let star = Child(PremiumStarComponent.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<CGPoint?>()
|
|
|
|
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 = star.update(
|
|
component: PremiumStarComponent(
|
|
isIntro: true,
|
|
isVisible: starIsVisible,
|
|
hasIdleAnimations: state.hasIdleAnimations,
|
|
colors: [
|
|
UIColor(rgb: 0xe57d02),
|
|
UIColor(rgb: 0xf09903),
|
|
UIColor(rgb: 0xf9b004),
|
|
UIColor(rgb: 0xfdd219)
|
|
]
|
|
),
|
|
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
|
|
if let requiredStars = context.component.requiredStars {
|
|
titleText = strings.Stars_Purchase_StarsNeeded(Int32(requiredStars))
|
|
} else {
|
|
titleText = strings.Stars_Purchase_GetStars
|
|
}
|
|
|
|
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.decimalSeparator),
|
|
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<EnvironmentType>(
|
|
content: AnyComponent(StarsPurchaseScreenContentComponent(
|
|
context: context.component.context,
|
|
externalState: contentExternalState,
|
|
containerSize: context.availableSize,
|
|
options: context.component.options,
|
|
peerId: context.component.peerId,
|
|
requiredStars: context.component.requiredStars,
|
|
selectedProductId: state.progressProduct?.storeProduct.id,
|
|
forceDark: context.component.forceDark,
|
|
products: state.products,
|
|
expanded: state.isExpanded,
|
|
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
|
|
fileprivate let options: [StarsTopUpOption]
|
|
|
|
private var didSetReady = false
|
|
private let _ready = Promise<Bool>()
|
|
public override var ready: Promise<Bool> {
|
|
return self._ready
|
|
}
|
|
|
|
public init(
|
|
context: AccountContext,
|
|
starsContext: StarsContext,
|
|
options: [StarsTopUpOption],
|
|
peerId: EnginePeer.Id?,
|
|
requiredStars: Int64?,
|
|
modal: Bool = true,
|
|
forceDark: Bool = false,
|
|
completion: @escaping (Int64) -> Void = { _ in }
|
|
) {
|
|
self.context = context
|
|
self.starsContext = starsContext
|
|
self.options = options
|
|
|
|
var updateInProgressImpl: ((Bool) -> Void)?
|
|
var presentImpl: ((ViewController) -> Void)?
|
|
var completionImpl: ((Int64) -> Void)?
|
|
super.init(context: context, component: StarsPurchaseScreenComponent(
|
|
context: context,
|
|
starsContext: starsContext,
|
|
options: options,
|
|
peerId: peerId,
|
|
requiredStars: requiredStars,
|
|
forceDark: forceDark,
|
|
updateInProgress: { inProgress in
|
|
updateInProgressImpl?(inProgress)
|
|
},
|
|
present: { c in
|
|
presentImpl?(c)
|
|
},
|
|
completion: { stars in
|
|
completionImpl?(stars)
|
|
}
|
|
), 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
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|