Files
Ilya Laktyushin 54ec998186 Various fixes
2025-12-27 21:50:29 +04:00

2221 lines
96 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import SwiftSignalKit
import Postbox
import TelegramCore
import Markdown
import TextFormat
import TelegramPresentationData
import ViewControllerComponent
import SheetComponent
import BalancedTextComponent
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import BundleIconComponent
import ButtonComponent
import AccountContext
import PresentationDataUtils
import ListSectionComponent
import TelegramStringFormatting
import UndoUI
import ListActionItemComponent
import PresentationDataUtils
import BalanceNeededScreen
import GlassBarButtonComponent
import GlassBackgroundComponent
import StarsBalanceOverlayComponent
import LottieComponent
import LottieComponentResourceContent
import EdgeEffect
import PlainButtonComponent
private let amountTag = GenericComponentViewTag()
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let gameInfo: EmojiGameInfo.Info
let controller: () -> ViewController?
let dismiss: () -> Void
init(
context: AccountContext,
gameInfo: EmojiGameInfo.Info,
controller: @escaping () -> ViewController?,
dismiss: @escaping () -> Void
) {
self.context = context
self.gameInfo = gameInfo
self.controller = controller
self.dismiss = dismiss
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
return true
}
static var body: (CombinedComponentContext<SheetContent>) -> CGSize {
let description = Child(BalancedTextComponent.self)
let resultsTitle = Child(MultilineTextComponent.self)
let results = Child(VStack<Empty>.self)
let resultsFooter = Child(MultilineTextWithEntitiesComponent.self)
let amountSection = Child(ListSectionComponent.self)
let button = Child(ButtonComponent.self)
let body: (CombinedComponentContext<SheetContent>) -> CGSize = { (context: CombinedComponentContext<SheetContent>) -> CGSize in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let state = context.state
state.component = component
let controller = environment.controller
let theme = environment.theme.withModalBlocksBackground()
let strings = environment.strings
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let textSideInset: CGFloat = 32.0 + environment.safeInsets.left
var contentSize = CGSize(width: context.availableSize.width, height: 75.0)
let textFont = Font.regular(15.0)
let boldTextFont = Font.semibold(15.0)
let textColor = theme.actionSheet.primaryTextColor
let linkColor = theme.actionSheet.controlAccentColor
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})
let description = description.update(
component: BalancedTextComponent(
text: .markdown(text: strings.EmojiStake_Description, attributes: markdownAttributes),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(description
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + description.size.height * 0.5))
)
contentSize.height += description.size.height
contentSize.height += 32.0
let resultsTitle = resultsTitle.update(
component: MultilineTextComponent(text: .plain(NSAttributedString(
string: strings.EmojiStake_ResultsTitle.uppercased(),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
))),
availableSize: context.availableSize,
transition: .immediate
)
context.add(resultsTitle
.position(CGPoint(x: textSideInset + resultsTitle.size.width * 0.5, y: contentSize.height + resultsTitle.size.height * 0.5))
)
contentSize.height += resultsTitle.size.height
contentSize.height += 6.0
let resultSpacing: CGFloat = 8.0
let resultSize = CGSize(width: (context.availableSize.width - sideInset * 2.0 - resultSpacing * 3.0) / 4.0, height: 64.0)
let doubleResultSize = CGSize(width: resultSize.width * 2.0 + resultSpacing, height: resultSize.height)
var resultValue1: Int32 = 0
var resultValue2: Int32 = 300
var resultValue3: Int32 = 600
var resultValue4: Int32 = 1300
var resultValue5: Int32 = 1600
var resultValue6: Int32 = 2000
var resultValue7: Int32 = 20000
if context.component.gameInfo.parameters.count == 7 {
resultValue1 = context.component.gameInfo.parameters[0]
resultValue2 = context.component.gameInfo.parameters[1]
resultValue3 = context.component.gameInfo.parameters[2]
resultValue4 = context.component.gameInfo.parameters[3]
resultValue5 = context.component.gameInfo.parameters[4]
resultValue6 = context.component.gameInfo.parameters[5]
resultValue7 = context.component.gameInfo.parameters[6]
}
let results = results.update(
component: VStack([
AnyComponentWithIdentity(id: "first", component: AnyComponent(
HStack([
AnyComponentWithIdentity(id: 1, component: AnyComponent(
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[1]] }, value: resultValue1, size: resultSize)
)),
AnyComponentWithIdentity(id: 2, component: AnyComponent(
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[2]] }, value: resultValue2, size: resultSize)
)),
AnyComponentWithIdentity(id: 3, component: AnyComponent(
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[3]] }, value: resultValue3, size: resultSize)
)),
AnyComponentWithIdentity(id: 4, component: AnyComponent(
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[4]] }, value: resultValue4, size: resultSize)
))
], spacing: resultSpacing)
)),
AnyComponentWithIdentity(id: "second", component: AnyComponent(
HStack([
AnyComponentWithIdentity(id: 5, component: AnyComponent(
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[5]] }, value: resultValue5, size: resultSize)
)),
AnyComponentWithIdentity(id: 6, component: AnyComponent(
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[6]] }, value: resultValue6, size: resultSize)
)),
AnyComponentWithIdentity(id: 7, component: AnyComponent(
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[6], $0[6], $0[6]] }, value: resultValue7, size: doubleResultSize)
)),
], spacing: resultSpacing)
))
], spacing: resultSpacing),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height),
transition: context.transition
)
context.add(results
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + results.size.height * 0.5))
)
contentSize.height += results.size.height
contentSize.height += 7.0
let resultsFooterAttributedText = NSMutableAttributedString(
string: strings.EmojiStake_StreakInfo,
font: Font.regular(13.0),
textColor: theme.list.freeTextColor
)
if let emojiFile = state.emojiFiles?[6] {
let range = (resultsFooterAttributedText.string as NSString).range(of: "#")
if range.location != NSNotFound {
resultsFooterAttributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: emojiFile, custom: .dice, enableAnimation: false), range: range)
}
}
let resultsFooter = resultsFooter.update(
component: MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: .clear,
text: .plain(resultsFooterAttributedText),
maximumNumberOfLines: 0,
enableLooping: true
),
availableSize: context.availableSize,
transition: .immediate
)
context.add(resultsFooter
.position(CGPoint(x: textSideInset + resultsFooter.size.width * 0.5, y: contentSize.height + resultsFooter.size.height * 0.5))
)
contentSize.height += resultsFooter.size.height
contentSize.height += 39.0
if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== environment.theme {
state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Contact List/SubtitleArrow"), color: environment.theme.list.itemAccentColor)!, environment.theme)
}
let configuration = EmojiGameStakeConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
var amountLabel = ""
if let tonUsdRate = configuration.tonUsdRate, let value = state.amount?.value, value > 0 {
amountLabel = "~\(formatTonUsdValue(value, divide: true, rate: tonUsdRate, dateTimeFormat: environment.dateTimeFormat))"
}
let amountItems: [AnyComponentWithIdentity<Empty>] = [
AnyComponentWithIdentity(
id: "amount",
component: AnyComponent(
AmountFieldComponent(
textColor: theme.list.itemPrimaryTextColor,
secondaryColor: theme.list.itemSecondaryTextColor,
placeholderColor: theme.list.itemPlaceholderTextColor,
accentColor: theme.list.itemAccentColor,
value: state.amount?.value,
minValue: 0,
forceMinValue: false,
allowZero: true,
maxValue: nil,
placeholderText: strings.EmojiStake_StakePlaceholder,
labelText: amountLabel,
currency: .ton,
dateTimeFormat: presentationData.dateTimeFormat,
amountUpdated: { [weak state] amount in
state?.amount = amount.flatMap { StarsAmount(value: $0, nanos: 0) }
state?.updated()
},
tag: amountTag
)
)
),
AnyComponentWithIdentity(id: "presets", component: AnyComponent(
AmountPresetsListItemComponent(
context: component.context,
theme: theme,
values: configuration.suggestedAmounts,
valueSelected: { [weak state] value in
guard let state else {
return
}
state.amount = StarsAmount(value: value, nanos: 0)
if let controller = controller() as? EmojiGameStakeScreen {
controller.dismissInput()
state.updated()
controller.resetValue()
}
}
)
))
]
let amountSection = amountSection.update(
component: ListSectionComponent(
theme: theme,
style: .glass,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: strings.EmojiStake_StakeTitle.uppercased(),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
footer: nil,
items: amountItems
),
environment: {},
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude),
transition: .immediate
)
context.add(amountSection
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + amountSection.size.height / 2.0))
.clipsToBounds(true)
.cornerRadius(10.0)
)
contentSize.height += amountSection.size.height
contentSize.height += 24.0
var buttonItems: [AnyComponentWithIdentity<Empty>] = []
buttonItems.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent(name: "Premium/Dice", tintColor: theme.list.itemCheckColors.foregroundColor))))
buttonItems.append(AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: environment.strings.EmojiStake_Roll, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))))
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0)
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(HStack(buttonItems, spacing: 7.0))
),
action: { [weak state] in
if let state, let amount = state.amount, let controller = controller() as? EmojiGameStakeScreen {
controller.complete(amount: amount)
}
}
),
availableSize: CGSize(width: context.availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0),
transition: .immediate
)
context.add(button
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
)
contentSize.height += button.size.height
if environment.inputHeight > 0.0 {
contentSize.height += 15.0
contentSize.height += max(environment.inputHeight, environment.safeInsets.bottom)
} else {
contentSize.height += buttonInsets.bottom
}
return contentSize
}
return body
}
final class State: ComponentState {
fileprivate let context: AccountContext
fileprivate var component: SheetContent
fileprivate var forceUpdateAmount = false
fileprivate var amount: StarsAmount?
fileprivate var currency: CurrencyAmount.Currency = .ton
var cachedChevronImage: (UIImage, PresentationTheme)?
var emojiFiles: [TelegramMediaFile]?
var emojiFilesDisposable: Disposable?
init(component: SheetContent) {
self.context = component.context
self.component = component
let amount: StarsAmount? = StarsAmount(value: component.gameInfo.previousStake, nanos: 0)
let currency: CurrencyAmount.Currency = .ton
self.currency = currency
self.amount = amount
super.init()
self.emojiFilesDisposable = (self.context.engine.stickers.loadedStickerPack(reference: .dice("🎲"), forceActualized: false)
|> mapToSignal { stickerPack -> Signal<[TelegramMediaFile], NoError> in
switch stickerPack {
case let .result(_, items, _):
var emojiStickers: [TelegramMediaFile] = []
for item in items {
emojiStickers.append(item.file._parse())
}
return .single(emojiStickers)
default:
return .complete()
}
}
|> deliverOnMainQueue).start(next: { [weak self] files in
guard let self else {
return
}
self.emojiFiles = files
self.updated()
})
}
deinit {
self.emojiFilesDisposable?.dispose()
}
}
func makeState() -> State {
return State(component: self)
}
}
private final class EmojiGameStakeSheetComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
private let context: AccountContext
private let gameInfo: EmojiGameInfo.Info
init(
context: AccountContext,
gameInfo: EmojiGameInfo.Info
) {
self.context = context
self.gameInfo = gameInfo
}
static func ==(lhs: EmojiGameStakeSheetComponent, rhs: EmojiGameStakeSheetComponent) -> Bool {
return true
}
static var body: Body {
let sheet = Child(ResizableSheetComponent<(EnvironmentType)>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let dismiss: (Bool) -> Void = { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() {
controller.dismiss(completion: nil)
}
}
}
let theme = environment.theme.withModalBlocksBackground()
var buttonItems: [AnyComponentWithIdentity<Empty>] = []
buttonItems.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(Image(image: PresentationResourcesItemList.itemListRoundTopupIcon(environment.theme), tintColor: theme.list.itemCheckColors.foregroundColor, size: CGSize(width: 16.0, height: 18.0)))))
buttonItems.append(AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: environment.strings.EmojiStake_Roll, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))))
let sheet = sheet.update(
component: ResizableSheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
gameInfo: context.component.gameInfo,
controller: {
return controller()
},
dismiss: {
dismiss(true)
}
)),
titleItem: AnyComponent(
Text(text: environment.strings.EmojiStake_Title, font: Font.bold(17.0), color: theme.list.itemPrimaryTextColor)
),
leftItem: AnyComponent(
GlassBarButtonComponent(
size: CGSize(width: 40.0, height: 40.0),
backgroundColor: theme.rootController.navigationBar.glassBarButtonBackgroundColor,
isDark: theme.overallDarkAppearance,
state: .generic,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: theme.chat.inputPanel.panelControlColor
)
)),
action: { _ in
dismiss(true)
}
)
),
bottomItem: AnyComponent(
ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(HStack(buttonItems, spacing: 7.0))
),
isEnabled: true,
displaysProgress: false,
action: {
dismiss(true)
}
)
),
backgroundColor: .color(theme.list.blocksBackgroundColor),
animateOut: animateOut
),
environment: {
environment
ResizableSheetComponentEnvironment(
theme: theme,
statusBarHeight: environment.statusBarHeight,
safeInsets: environment.safeInsets,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
dismiss(animated)
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
return context.availableSize
}
}
}
public final class EmojiGameStakeScreen: ViewControllerComponentContainer {
private let context: AccountContext
fileprivate let completion: (StarsAmount) -> Void
fileprivate let balanceOverlay = ComponentView<Empty>()
private var showBalance = true
public init(
context: AccountContext,
gameInfo: EmojiGameInfo.Info,
completion: @escaping (StarsAmount) -> Void
) {
self.context = context
self.completion = completion
super.init(
context: context,
component: EmojiGameStakeSheetComponent(
context: context,
gameInfo: gameInfo
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
self.context.tonContext?.load(force: true)
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func dismissInput() {
if let view = self.node.hostView.findTaggedView(tag: amountTag) as? AmountFieldComponent.View {
view.deactivateInput()
}
}
fileprivate func resetValue() {
if let view = self.node.hostView.findTaggedView(tag: amountTag) as? AmountFieldComponent.View {
view.resetValue()
}
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
func complete(amount: StarsAmount) {
if let tonState = self.context.tonContext?.currentState, tonState.balance < amount {
let needed = amount - tonState.balance
var fragmentUrl = "https://fragment.com/ads/topup"
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let value = data["ton_topup_url"] as? String {
fragmentUrl = value
}
self.push(BalanceNeededScreen(
context: self.context,
amount: needed,
buttonAction: { [weak self] in
self?.context.sharedContext.applicationBindings.openUrl(fragmentUrl)
}
))
} else {
self.completion(amount)
self.dismissAnimated()
}
}
func dismissBalanceOverlay() {
if let view = self.balanceOverlay.view, view.superview != nil {
view.alpha = 0.0
view.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4)
view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { _ in
view.removeFromSuperview()
view.alpha = 1.0
})
}
}
public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
if self.showBalance {
let context = self.context
let insets = layout.insets(options: .statusBar)
let balanceSize = self.balanceOverlay.update(
transition: .immediate,
component: AnyComponent(
StarsBalanceOverlayComponent(
context: context,
peerId: context.account.peerId,
theme: context.sharedContext.currentPresentationData.with { $0 }.theme,
currency: .ton,
action: { [weak self] in
guard let self else {
return
}
var fragmentUrl = "https://fragment.com/ads/topup"
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ton_topup_url"] as? String {
fragmentUrl = value
}
context.sharedContext.applicationBindings.openUrl(fragmentUrl)
self.dismissAnimated()
}
)
),
environment: {},
containerSize: layout.size
)
if let view = self.balanceOverlay.view {
if view.superview == nil {
self.view.addSubview(view)
view.layer.animatePosition(from: CGPoint(x: 0.0, y: -64.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
view.layer.animateSpring(from: 0.8 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 0.0, removeOnCompletion: true, additive: false, completion: nil)
view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - balanceSize.width) / 2.0), y: insets.top + 5.0), size: balanceSize)
}
} else if let view = self.balanceOverlay.view, view.superview != nil {
view.alpha = 0.0
view.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4)
view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { _ in
view.removeFromSuperview()
view.alpha = 1.0
})
}
}
}
private final class AmountFieldStarsFormatter: NSObject, UITextFieldDelegate {
private let currency: CurrencyAmount.Currency
private let dateTimeFormat: PresentationDateTimeFormat
private let textField: UITextField
private let minValue: Int64
private let forceMinValue: Bool
private let allowZero: Bool
private let maxValue: Int64
private let updated: (Int64) -> Void
private let isEmptyUpdated: (Bool) -> Void
private let animateError: () -> Void
private let focusUpdated: (Bool) -> Void
init?(textField: UITextField, currency: CurrencyAmount.Currency, dateTimeFormat: PresentationDateTimeFormat, minValue: Int64, forceMinValue: Bool, allowZero: Bool, maxValue: Int64, updated: @escaping (Int64) -> Void, isEmptyUpdated: @escaping (Bool) -> Void, animateError: @escaping () -> Void, focusUpdated: @escaping (Bool) -> Void) {
self.textField = textField
self.currency = currency
self.dateTimeFormat = dateTimeFormat
self.minValue = minValue
self.forceMinValue = forceMinValue
self.allowZero = allowZero
self.maxValue = maxValue
self.updated = updated
self.isEmptyUpdated = isEmptyUpdated
self.animateError = animateError
self.focusUpdated = focusUpdated
super.init()
}
func amountFrom(text: String) -> Int64 {
var amount: Int64?
if !text.isEmpty {
switch self.currency {
case .stars:
if let value = Int64(text) {
amount = value
}
case .ton:
let scale: Int64 = 1_000_000_000 // 10 (one nano)
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, let dot = text.firstIndex(of: decimalSeparator) {
// Slices for the parts on each side of the dot
var wholeSlice = String(text[..<dot])
if wholeSlice.isEmpty {
wholeSlice = "0"
}
let fractionSlice = text[text.index(after: dot)...]
// Make the fractional string exactly 9 characters long
var fractionStr = String(fractionSlice)
if fractionStr.count > 9 {
fractionStr = String(fractionStr.prefix(9)) // trim extra digits
} else {
fractionStr = fractionStr.padding(
toLength: 9, withPad: "0", startingAt: 0) // pad with zeros
}
// Convert and combine
if let whole = Int64(wholeSlice),
let frac = Int64(fractionStr) {
let whole = min(whole, Int64.max / scale)
amount = whole * scale + frac
}
} else if let whole = Int64(text) { // string had no dot at all
let whole = min(whole, Int64.max / scale)
amount = whole * scale
}
}
}
return amount ?? 0
}
func onTextChanged(text: String) {
self.updated(self.amountFrom(text: text))
self.isEmptyUpdated(text.isEmpty)
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
var acceptZero = false
if case .ton = self.currency, self.minValue < 1_000_000_000 {
acceptZero = true
}
var newText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string)
if newText.contains(where: { c in
switch c {
case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":
return false
default:
if case .ton = self.currency {
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, c == decimalSeparator {
return false
}
}
return true
}
}) {
return false
}
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, newText.count(where: { $0 == decimalSeparator }) > 1 {
return false
}
switch self.currency {
case .stars:
if (newText == "0" && !acceptZero) || (newText.count > 1 && newText.hasPrefix("0")) {
newText.removeFirst()
textField.text = newText
self.onTextChanged(text: newText)
return false
}
case .ton:
var fixedText = false
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, let index = newText.firstIndex(of: decimalSeparator) {
let fractionalString = newText[newText.index(after: index)...]
if fractionalString.count > 2 {
newText = String(newText[newText.startIndex ..< newText.index(index, offsetBy: 3)])
fixedText = true
}
}
if newText == self.dateTimeFormat.decimalSeparator {
if !acceptZero {
newText.removeFirst()
} else {
newText = "0\(newText)"
}
fixedText = true
}
if (newText == "0" && !acceptZero) || (newText.count > 1 && newText.hasPrefix("0") && !newText.hasPrefix("0\(self.dateTimeFormat.decimalSeparator)")) {
newText.removeFirst()
fixedText = true
}
if fixedText {
textField.text = newText
self.onTextChanged(text: newText)
return false
}
}
let amount: Int64 = self.amountFrom(text: newText)
if self.forceMinValue && amount < self.minValue {
switch self.currency {
case .stars:
textField.text = "\(self.minValue)"
case .ton:
textField.text = "\(formatTonAmountText(self.minValue, dateTimeFormat: PresentationDateTimeFormat(timeFormat: self.dateTimeFormat.timeFormat, dateFormat: self.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: self.dateTimeFormat.decimalSeparator, groupingSeparator: ""), maxDecimalPositions: nil))"
}
self.onTextChanged(text: self.textField.text ?? "")
self.animateError()
return false
} else if amount > self.maxValue {
switch self.currency {
case .stars:
textField.text = "\(self.maxValue)"
case .ton:
textField.text = "\(formatTonAmountText(self.maxValue, dateTimeFormat: PresentationDateTimeFormat(timeFormat: self.dateTimeFormat.timeFormat, dateFormat: self.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: self.dateTimeFormat.decimalSeparator, groupingSeparator: ""), maxDecimalPositions: nil))"
}
self.onTextChanged(text: self.textField.text ?? "")
self.animateError()
return false
}
self.onTextChanged(text: newText)
return true
}
}
public final class AmountFieldComponent: Component {
public typealias EnvironmentType = Empty
let textColor: UIColor
let secondaryColor: UIColor
let placeholderColor: UIColor
let accentColor: UIColor
let value: Int64?
let minValue: Int64?
let forceMinValue: Bool
let allowZero: Bool
let maxValue: Int64?
let placeholderText: String
let textFieldOffset: CGPoint
let labelText: String?
let currency: CurrencyAmount.Currency
let dateTimeFormat: PresentationDateTimeFormat
let amountUpdated: (Int64?) -> Void
let tag: AnyObject?
public init(
textColor: UIColor,
secondaryColor: UIColor,
placeholderColor: UIColor,
accentColor: UIColor,
value: Int64?,
minValue: Int64?,
forceMinValue: Bool,
allowZero: Bool,
maxValue: Int64?,
placeholderText: String,
textFieldOffset: CGPoint = .zero,
labelText: String?,
currency: CurrencyAmount.Currency,
dateTimeFormat: PresentationDateTimeFormat,
amountUpdated: @escaping (Int64?) -> Void,
tag: AnyObject? = nil
) {
self.textColor = textColor
self.secondaryColor = secondaryColor
self.placeholderColor = placeholderColor
self.accentColor = accentColor
self.value = value
self.minValue = minValue
self.forceMinValue = forceMinValue
self.allowZero = allowZero
self.maxValue = maxValue
self.placeholderText = placeholderText
self.textFieldOffset = textFieldOffset
self.labelText = labelText
self.currency = currency
self.dateTimeFormat = dateTimeFormat
self.amountUpdated = amountUpdated
self.tag = tag
}
public static func ==(lhs: AmountFieldComponent, rhs: AmountFieldComponent) -> Bool {
if lhs.textColor != rhs.textColor {
return false
}
if lhs.secondaryColor != rhs.secondaryColor {
return false
}
if lhs.placeholderColor != rhs.placeholderColor {
return false
}
if lhs.accentColor != rhs.accentColor {
return false
}
if lhs.value != rhs.value {
return false
}
if lhs.minValue != rhs.minValue {
return false
}
if lhs.allowZero != rhs.allowZero {
return false
}
if lhs.maxValue != rhs.maxValue {
return false
}
if lhs.placeholderText != rhs.placeholderText {
return false
}
if lhs.labelText != rhs.labelText {
return false
}
if lhs.currency != rhs.currency {
return false
}
return true
}
public final class View: UIView, ListSectionComponent.ChildView, UITextFieldDelegate, ComponentTaggedView {
public func matches(tag: Any) -> Bool {
if let component = self.component, let componentTag = component.tag {
let tag = tag as AnyObject
if componentTag === tag {
return true
}
}
return false
}
private let placeholderView: ComponentView<Empty>
private let icon = ComponentView<Empty>()
private let textField: TextFieldNodeView
private var starsFormatter: AmountFieldStarsFormatter?
private var tonFormatter: AmountFieldStarsFormatter?
private let labelView: ComponentView<Empty>
private var component: AmountFieldComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
private var didSetValueOnce = false
public var customUpdateIsHighlighted: ((Bool) -> Void)?
public var enumerateSiblings: (((UIView) -> Void) -> Void)?
public let separatorInset: CGFloat = 16.0
public override init(frame: CGRect) {
self.placeholderView = ComponentView<Empty>()
self.textField = TextFieldNodeView(frame: .zero)
self.labelView = ComponentView<Empty>()
super.init(frame: frame)
self.addSubview(self.textField)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func activateInput() {
self.textField.becomeFirstResponder()
}
public func deactivateInput() {
self.textField.resignFirstResponder()
}
public func selectAll() {
self.textField.selectAll(nil)
}
public func animateError() {
self.textField.layer.addShakeAnimation()
let hapticFeedback = HapticFeedback()
hapticFeedback.error()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0, execute: {
let _ = hapticFeedback
})
}
public func resetValue() {
guard let component = self.component, let value = component.value else {
return
}
var text = ""
switch component.currency {
case .stars:
text = "\(value)"
case .ton:
text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: ".", groupingSeparator: ""), maxDecimalPositions: nil))"
}
self.textField.text = text
self.placeholderView.view?.isHidden = !(self.textField.text ?? "").isEmpty
}
func update(component: AmountFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.textField.textColor = component.textColor
if self.component?.currency != component.currency || ((self.textField.text ?? "").isEmpty && !self.didSetValueOnce) {
if let value = component.value, value != .zero {
var text = ""
switch component.currency {
case .stars:
text = "\(value)"
case .ton:
text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: ".", groupingSeparator: ""), maxDecimalPositions: nil))"
}
self.textField.text = text
self.placeholderView.view?.isHidden = !text.isEmpty
} else {
self.textField.text = ""
}
self.didSetValueOnce = true
}
self.textField.font = Font.regular(17.0)
self.textField.returnKeyType = .done
self.textField.autocorrectionType = .no
self.textField.autocapitalizationType = .none
if self.component?.currency != component.currency {
switch component.currency {
case .stars:
self.textField.delegate = self
self.textField.keyboardType = .numberPad
if self.starsFormatter == nil {
self.starsFormatter = AmountFieldStarsFormatter(
textField: self.textField,
currency: component.currency,
dateTimeFormat: component.dateTimeFormat,
minValue: component.minValue ?? 0,
forceMinValue: component.forceMinValue,
allowZero: component.allowZero,
maxValue: component.maxValue ?? Int64.max,
updated: { [weak self] value in
guard let self, let component = self.component else {
return
}
if !self.isUpdating {
component.amountUpdated(value == 0 ? nil : value)
}
},
isEmptyUpdated: { [weak self] isEmpty in
guard let self else {
return
}
self.placeholderView.view?.isHidden = !isEmpty
},
animateError: { [weak self] in
guard let self else {
return
}
self.animateError()
},
focusUpdated: { _ in
}
)
}
self.tonFormatter = nil
self.textField.delegate = self.starsFormatter
case .ton:
self.textField.keyboardType = .decimalPad
if self.tonFormatter == nil {
self.tonFormatter = AmountFieldStarsFormatter(
textField: self.textField,
currency: component.currency,
dateTimeFormat: component.dateTimeFormat,
minValue: component.minValue ?? 0,
forceMinValue: component.forceMinValue,
allowZero: component.allowZero,
maxValue: component.maxValue ?? Int64.max,
updated: { [weak self] value in
guard let self, let component = self.component else {
return
}
if !self.isUpdating {
component.amountUpdated(value == 0 ? nil : value)
}
},
isEmptyUpdated: { [weak self] isEmpty in
guard let self else {
return
}
self.placeholderView.view?.isHidden = !isEmpty
},
animateError: { [weak self] in
guard let self else {
return
}
self.animateError()
},
focusUpdated: { _ in
}
)
}
self.starsFormatter = nil
self.textField.delegate = self.tonFormatter
}
self.textField.reloadInputViews()
}
self.component = component
self.state = state
let size = CGSize(width: availableSize.width, height: 52.0)
let sideInset: CGFloat = 16.0
var leftInset: CGFloat = 16.0
let iconName: String
var iconTintColor: UIColor?
let iconMaxSize: CGSize?
var iconOffset = CGPoint()
switch component.currency {
case .stars:
iconName = "Premium/Stars/StarLarge"
iconMaxSize = CGSize(width: 22.0, height: 22.0)
case .ton:
iconName = "Ads/TonBig"
iconTintColor = component.accentColor
iconMaxSize = CGSize(width: 18.0, height: 18.0)
iconOffset = CGPoint(x: 3.0, y: 1.0)
}
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: iconName,
tintColor: iconTintColor,
maxSize: iconMaxSize
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
iconView.frame = CGRect(origin: CGPoint(x: iconOffset.x + 15.0, y: iconOffset.y - 1.0 + floorToScreenPixels((size.height - iconSize.height) / 2.0)), size: iconSize)
}
leftInset += 24.0 + 6.0
let placeholderSize = self.placeholderView.update(
transition: .easeInOut(duration: 0.2),
component: AnyComponent(
Text(
text: component.placeholderText,
font: Font.regular(17.0),
color: component.placeholderColor
)
),
environment: {},
containerSize: availableSize
)
if let placeholderComponentView = self.placeholderView.view {
if placeholderComponentView.superview == nil {
self.insertSubview(placeholderComponentView, at: 0)
}
placeholderComponentView.frame = CGRect(origin: CGPoint(x: leftInset, y: -1.0 + floorToScreenPixels((size.height - placeholderSize.height) / 2.0) + 1.0 - UIScreenPixel), size: placeholderSize)
placeholderComponentView.isHidden = !(self.textField.text ?? "").isEmpty
}
if let labelText = component.labelText {
let labelSize = self.labelView.update(
transition: .immediate,
component: AnyComponent(
Text(
text: labelText,
font: Font.regular(17.0),
color: component.secondaryColor
)
),
environment: {},
containerSize: availableSize
)
if let labelView = self.labelView.view {
if labelView.superview == nil {
self.insertSubview(labelView, at: 0)
}
labelView.frame = CGRect(origin: CGPoint(x: size.width - sideInset - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0) + 1.0 - UIScreenPixel), size: labelSize)
}
} else if let labelView = self.labelView.view, labelView.superview != nil {
labelView.removeFromSuperview()
}
self.textField.frame = CGRect(x: leftInset + component.textFieldOffset.x, y: 4.0 + component.textFieldOffset.y, width: size.width - 30.0, height: 44.0)
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class ResultCellComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let files: [TelegramMediaFile]?
let value: Int32
let size: CGSize
init(
context: AccountContext,
theme: PresentationTheme,
files: [TelegramMediaFile]?,
value: Int32,
size: CGSize
) {
self.context = context
self.theme = theme
self.files = files
self.value = value
self.size = size
}
static func ==(lhs: ResultCellComponent, rhs: ResultCellComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.files != rhs.files {
return false
}
if lhs.value != rhs.value {
return false
}
if lhs.size != rhs.size {
return false
}
return true
}
final class View: UIView {
private var component: ResultCellComponent?
private let background = ComponentView<Empty>()
private let emoji = ComponentView<Empty>()
private let title = ComponentView<Empty>()
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ResultCellComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let size = component.size
let backgroundSize = self.background.update(
transition: transition,
component: AnyComponent(FilledRoundedRectangleComponent(color: component.theme.list.itemBlocksBackgroundColor, cornerRadius: .value(22.0), smoothCorners: true)),
environment: {},
containerSize: size
)
let backgroundFrame = CGRect(origin: .zero, size: backgroundSize)
if let backgroundView = self.background.view {
if backgroundView.superview == nil {
self.addSubview(backgroundView)
}
transition.setFrame(view: backgroundView, frame: backgroundFrame)
}
let value = Double(component.value) / 1000.0
let titleString = String(format: "%0.1f", value).replacingOccurrences(of: ".0", with: "").replacingOccurrences(of: ",0", with: "")
var items: [AnyComponentWithIdentity<Empty>] = []
if let files = component.files {
for file in files {
items.append(AnyComponentWithIdentity(id: items.count, component: AnyComponent(
LottieComponent(
content: LottieComponent.ResourceContent(
context: component.context,
file: file,
attemptSynchronously: true,
providesPlaceholder: true
),
placeholderColor: component.theme.list.mediaPlaceholderColor,
startingPosition: .end,
size: CGSize(width: 50.0, height: 50.0),
loop: false
)
)))
}
}
let emojiSize = self.emoji.update(
transition: transition,
component: AnyComponent(
HStack(items, spacing: -18.0)
),
environment: {},
containerSize: availableSize
)
let emojiFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - emojiSize.width) / 2.0), y: -9.0), size: emojiSize)
if let emojiView = self.emoji.view {
if emojiView.superview == nil {
self.addSubview(emojiView)
}
transition.setFrame(view: emojiView, frame: emojiFrame)
}
let titleSize = self.title.update(
transition: transition,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: "×\(titleString)", font: Font.semibold(11.0), textColor: component.theme.list.itemPrimaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 1
)
),
environment: {},
containerSize: availableSize
)
let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: size.height - titleSize.height - 10.0), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: titleFrame)
}
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class AmountPresetsListItemComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let values: [Int64]
let valueSelected: (Int64) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
values: [Int64],
valueSelected: @escaping (Int64) -> Void
) {
self.context = context
self.theme = theme
self.values = values
self.valueSelected = valueSelected
}
static func ==(lhs: AmountPresetsListItemComponent, rhs: AmountPresetsListItemComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.values != rhs.values {
return false
}
return true
}
final class View: UIView {
private var component: AmountPresetsListItemComponent?
private var itemViews: [Int64: ComponentView<Empty>] = [:]
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: AmountPresetsListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let sideInset: CGFloat = 16.0
let spacing: CGFloat = 6.0
let itemSize = CGSize(width: floorToScreenPixels((availableSize.width - sideInset * 2.0 - spacing * 2.0) / 3.0), height: 28.0)
var itemOrigin = CGPoint(x: sideInset, y: sideInset)
for value in component.values {
let itemView: ComponentView<Empty>
if let current = self.itemViews[value] {
itemView = current
} else {
itemView = ComponentView()
self.itemViews[value] = itemView
}
let _ = itemView.update(
transition: .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(AmountPresetComponent(
context: component.context,
theme: component.theme,
value: value
)),
action: {
component.valueSelected(value)
},
animateScale: false
)),
environment: {},
containerSize: itemSize
)
var itemFrame = CGRect(origin: itemOrigin, size: itemSize)
if itemFrame.maxX > availableSize.width {
itemOrigin = CGPoint(x: sideInset, y: itemOrigin.y + itemSize.height + spacing)
itemFrame.origin = itemOrigin
}
if let itemView = itemView.view {
if itemView.superview == nil {
self.addSubview(itemView)
}
transition.setFrame(view: itemView, frame: itemFrame)
}
itemOrigin.x += itemSize.width + spacing
}
let size = CGSize(width: availableSize.width, height: itemOrigin.y + itemSize.height + sideInset)
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class AmountPresetComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let value: Int64
init(
context: AccountContext,
theme: PresentationTheme,
value: Int64
) {
self.context = context
self.theme = theme
self.value = value
}
static func ==(lhs: AmountPresetComponent, rhs: AmountPresetComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.value != rhs.value {
return false
}
return true
}
final class View: UIView {
private var component: AmountPresetComponent?
private let background = ComponentView<Empty>()
private let title = ComponentView<Empty>()
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: AmountPresetComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let size = availableSize
let backgroundSize = self.background.update(
transition: transition,
component: AnyComponent(FilledRoundedRectangleComponent(color: component.theme.list.itemAccentColor.withMultipliedAlpha(0.1), cornerRadius: .minEdge, smoothCorners: false)),
environment: {},
containerSize: size
)
let backgroundFrame = CGRect(origin: .zero, size: backgroundSize)
if let backgroundView = self.background.view {
if backgroundView.superview == nil {
self.addSubview(backgroundView)
}
transition.setFrame(view: backgroundView, frame: backgroundFrame)
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let attributedText = NSMutableAttributedString(string: "$ \(formatTonAmountText(component.value, dateTimeFormat: presentationData.dateTimeFormat))", font: Font.semibold(14.0), textColor: component.theme.list.itemAccentColor)
if let range = attributedText.string.range(of: "$") {
attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .ton(tinted: true)), range: NSRange(range, in: attributedText.string))
attributedText.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: attributedText.string))
}
let titleSize = self.title.update(
transition: transition,
component: AnyComponent(
MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: .clear,
text: .plain(attributedText)
)
),
environment: {},
containerSize: availableSize
)
let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: titleFrame)
}
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private struct EmojiGameStakeConfiguration {
static var defaultValue: EmojiGameStakeConfiguration {
return EmojiGameStakeConfiguration(
tonUsdRate: nil,
minStakeAmount: nil,
maxStakeAmount: nil,
suggestedAmounts: [
100000000,
1000000000,
2000000000,
5000000000,
10000000000,
20000000000
]
)
}
let tonUsdRate: Double?
let minStakeAmount: Int64?
let maxStakeAmount: Int64?
let suggestedAmounts: [Int64]
fileprivate init(
tonUsdRate: Double?,
minStakeAmount: Int64?,
maxStakeAmount: Int64?,
suggestedAmounts: [Int64]
) {
self.tonUsdRate = tonUsdRate
self.minStakeAmount = minStakeAmount
self.maxStakeAmount = maxStakeAmount
self.suggestedAmounts = suggestedAmounts
}
static func with(appConfiguration: AppConfiguration) -> EmojiGameStakeConfiguration {
if let data = appConfiguration.data {
var tonUsdRate: Double?
if let value = data["ton_usd_rate"] as? Double {
tonUsdRate = value
}
var minStakeAmount: Int64?
if let value = data["ton_stakedice_stake_amount_min"] as? Double {
minStakeAmount = Int64(value)
}
var maxStakeAmount: Int64?
if let value = data["ton_stakedice_stake_amount_max"] as? Double {
maxStakeAmount = Int64(value)
}
var suggestedAmounts: [Int64] = []
if let value = data["ton_stakedice_stake_suggested_amounts"] as? [Double] {
suggestedAmounts = value.map { Int64($0) }
} else {
suggestedAmounts = EmojiGameStakeConfiguration.defaultValue.suggestedAmounts
}
return EmojiGameStakeConfiguration(
tonUsdRate: tonUsdRate,
minStakeAmount: minStakeAmount,
maxStakeAmount: maxStakeAmount,
suggestedAmounts: suggestedAmounts
)
} else {
return .defaultValue
}
}
}
public final class ResizableSheetComponentEnvironment: Equatable {
public let theme: PresentationTheme
public let statusBarHeight: CGFloat
public let safeInsets: UIEdgeInsets
public let metrics: LayoutMetrics
public let deviceMetrics: DeviceMetrics
public let isDisplaying: Bool
public let isCentered: Bool
public let regularMetricsSize: CGSize?
public let dismiss: (Bool) -> Void
public init(
theme: PresentationTheme,
statusBarHeight: CGFloat,
safeInsets: UIEdgeInsets,
metrics: LayoutMetrics,
deviceMetrics: DeviceMetrics,
isDisplaying: Bool,
isCentered: Bool,
regularMetricsSize: CGSize?,
dismiss: @escaping (Bool) -> Void
) {
self.theme = theme
self.statusBarHeight = statusBarHeight
self.safeInsets = safeInsets
self.metrics = metrics
self.deviceMetrics = deviceMetrics
self.isDisplaying = isDisplaying
self.isCentered = isCentered
self.regularMetricsSize = regularMetricsSize
self.dismiss = dismiss
}
public static func ==(lhs: ResizableSheetComponentEnvironment, rhs: ResizableSheetComponentEnvironment) -> Bool {
if lhs.theme != rhs.theme {
return false
}
if lhs.statusBarHeight != rhs.statusBarHeight {
return false
}
if lhs.safeInsets != rhs.safeInsets {
return false
}
if lhs.metrics != rhs.metrics {
return false
}
if lhs.deviceMetrics != rhs.deviceMetrics {
return false
}
if lhs.isDisplaying != rhs.isDisplaying {
return false
}
if lhs.isCentered != rhs.isCentered {
return false
}
if lhs.regularMetricsSize != rhs.regularMetricsSize {
return false
}
return true
}
}
public final class ResizableSheetComponent<ChildEnvironmentType: Sendable & Equatable>: Component {
public typealias EnvironmentType = (ChildEnvironmentType, ResizableSheetComponentEnvironment)
public class ExternalState {
public fileprivate(set) var contentHeight: CGFloat
public init() {
self.contentHeight = 0.0
}
}
public enum BackgroundColor: Equatable {
case color(UIColor)
}
public let content: AnyComponent<ChildEnvironmentType>
public let titleItem: AnyComponent<Empty>?
public let leftItem: AnyComponent<Empty>?
public let rightItem: AnyComponent<Empty>?
public let bottomItem: AnyComponent<Empty>?
public let backgroundColor: BackgroundColor
public let externalState: ExternalState?
public let animateOut: ActionSlot<Action<()>>
public init(
content: AnyComponent<ChildEnvironmentType>,
titleItem: AnyComponent<Empty>? = nil,
leftItem: AnyComponent<Empty>? = nil,
rightItem: AnyComponent<Empty>? = nil,
bottomItem: AnyComponent<Empty>? = nil,
backgroundColor: BackgroundColor,
externalState: ExternalState? = nil,
animateOut: ActionSlot<Action<()>>,
) {
self.content = content
self.titleItem = titleItem
self.leftItem = leftItem
self.rightItem = rightItem
self.bottomItem = bottomItem
self.backgroundColor = backgroundColor
self.externalState = externalState
self.animateOut = animateOut
}
public static func ==(lhs: ResizableSheetComponent, rhs: ResizableSheetComponent) -> Bool {
if lhs.content != rhs.content {
return false
}
if lhs.titleItem != rhs.titleItem {
return false
}
if lhs.leftItem != rhs.leftItem {
return false
}
if lhs.rightItem != rhs.rightItem {
return false
}
if lhs.bottomItem != rhs.bottomItem {
return false
}
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.animateOut != rhs.animateOut {
return false
}
return true
}
private struct ItemLayout: Equatable {
var containerSize: CGSize
var containerInset: CGFloat
var containerCornerRadius: CGFloat
var bottomInset: CGFloat
var topInset: CGFloat
init(containerSize: CGSize, containerInset: CGFloat, containerCornerRadius: CGFloat, bottomInset: CGFloat, topInset: CGFloat) {
self.containerSize = containerSize
self.containerInset = containerInset
self.containerCornerRadius = containerCornerRadius
self.bottomInset = bottomInset
self.topInset = topInset
}
}
private final class ScrollView: UIScrollView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
}
public final class View: UIView, UIScrollViewDelegate, ComponentTaggedView {
public final class Tag {
public init() {
}
}
public func matches(tag: Any) -> Bool {
if let _ = tag as? Tag {
return true
}
return false
}
private let dimView: UIView
private let containerView: UIView
private let backgroundLayer: SimpleLayer
private let navigationBarContainer: SparseContainerView
private let scrollView: ScrollView
private let scrollContentClippingView: SparseContainerView
private let scrollContentView: UIView
private let topEdgeEffectView: EdgeEffectView
private let contentView: ComponentView<ChildEnvironmentType>
private var titleItemView: ComponentView<Empty>?
private var leftItemView: ComponentView<Empty>?
private var rightItemView: ComponentView<Empty>?
private var bottomItemView: ComponentView<Empty>?
private let backgroundHandleView: UIImageView
private var ignoreScrolling: Bool = false
private var component: ResizableSheetComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
private var environment: ResizableSheetComponentEnvironment?
private var itemLayout: ItemLayout?
override init(frame: CGRect) {
self.dimView = UIView()
self.containerView = UIView()
self.containerView.clipsToBounds = true
self.containerView.layer.cornerRadius = 40.0
self.containerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
self.backgroundLayer = SimpleLayer()
self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
self.backgroundLayer.cornerRadius = 40.0
self.backgroundHandleView = UIImageView()
self.navigationBarContainer = SparseContainerView()
self.scrollView = ScrollView()
self.scrollContentClippingView = SparseContainerView()
self.scrollContentClippingView.clipsToBounds = true
self.scrollContentView = UIView()
self.topEdgeEffectView = EdgeEffectView()
self.topEdgeEffectView.clipsToBounds = true
self.topEdgeEffectView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
self.topEdgeEffectView.layer.cornerRadius = 40.0
self.contentView = ComponentView()
super.init(frame: frame)
self.addSubview(self.dimView)
self.addSubview(self.containerView)
self.containerView.layer.addSublayer(self.backgroundLayer)
self.scrollView.delaysContentTouches = true
self.scrollView.canCancelContentTouches = true
self.scrollView.clipsToBounds = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = true
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.containerView.addSubview(self.scrollContentClippingView)
self.scrollContentClippingView.addSubview(self.scrollView)
self.scrollView.addSubview(self.scrollContentView)
self.containerView.addSubview(self.navigationBarContainer)
self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.bounds.contains(point) {
return nil
}
if !self.backgroundLayer.frame.contains(point) {
return self.dimView
}
if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) {
return result
}
let result = super.hitTest(point, with: event)
return result
}
@objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.dismissAnimated()
}
}
func dismissAnimated() {
guard let environment = self.environment else {
return
}
self.endEditing(true)
environment.dismiss(true)
}
private func updateScrolling(transition: ComponentTransition) {
guard let itemLayout = self.itemLayout else {
return
}
var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset
topOffset = max(0.0, topOffset)
transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0))
transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset))
var topOffsetFraction = self.scrollView.bounds.minY / 100.0
topOffsetFraction = max(0.0, min(1.0, topOffsetFraction))
let minScale: CGFloat = (itemLayout.containerSize.width - 6.0 * 2.0) / itemLayout.containerSize.width
let minScaledTranslation: CGFloat = (itemLayout.containerSize.height - itemLayout.containerSize.height * minScale) * 0.5 - 6.0
let minScaledCornerRadius: CGFloat = itemLayout.containerCornerRadius
let scale = minScale * (1.0 - topOffsetFraction) + 1.0 * topOffsetFraction
let scaledTranslation = minScaledTranslation * (1.0 - topOffsetFraction)
let scaledCornerRadius = minScaledCornerRadius * (1.0 - topOffsetFraction) + itemLayout.containerCornerRadius * topOffsetFraction
var containerTransform = CATransform3DIdentity
containerTransform = CATransform3DTranslate(containerTransform, 0.0, scaledTranslation, 0.0)
containerTransform = CATransform3DScale(containerTransform, scale, scale, scale)
transition.setTransform(view: self.containerView, transform: containerTransform)
transition.setCornerRadius(layer: self.containerView.layer, cornerRadius: scaledCornerRadius)
}
private var didPlayAppearanceAnimation = false
func animateIn() {
self.didPlayAppearanceAnimation = true
self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY
self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
func animateOut(completion: @escaping () -> Void) {
let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY
self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in
completion()
})
self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
}
func update(component: ResizableSheetComponent<ChildEnvironmentType>, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let sheetEnvironment = environment[ResizableSheetComponentEnvironment.self].value
component.animateOut.connect { [weak self] completion in
guard let self else {
return
}
self.animateOut {
completion(Void())
}
}
let themeUpdated = self.environment?.theme !== sheetEnvironment.theme
let resetScrolling = self.scrollView.bounds.width != availableSize.width
let fillingSize: CGFloat
if case .regular = sheetEnvironment.metrics.widthClass {
fillingSize = min(availableSize.width, 414.0) - sheetEnvironment.safeInsets.left * 2.0
} else {
fillingSize = min(availableSize.width, sheetEnvironment.deviceMetrics.screenSize.width) - sheetEnvironment.safeInsets.left * 2.0
}
let rawSideInset: CGFloat = floor((availableSize.width - fillingSize) * 0.5)
self.component = component
self.state = state
self.environment = sheetEnvironment
let theme = sheetEnvironment.theme.withModalBlocksBackground()
if themeUpdated {
self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.backgroundLayer.backgroundColor = theme.list.blocksBackgroundColor.cgColor
}
transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize))
var containerSize: CGSize
if !"".isEmpty, sheetEnvironment.isCentered {
let verticalInset: CGFloat = 44.0
let maxSide = max(availableSize.width, availableSize.height)
let minSide = min(availableSize.width, availableSize.height)
containerSize = CGSize(width: min(availableSize.width - 20.0, floor(maxSide / 2.0)), height: min(availableSize.height, minSide) - verticalInset * 2.0)
if let regularMetricsSize = sheetEnvironment.regularMetricsSize {
containerSize = regularMetricsSize
}
} else {
containerSize = CGSize(width: fillingSize, height: .greatestFiniteMagnitude)
}
let containerInset: CGFloat = sheetEnvironment.statusBarHeight + 10.0
let clippingY: CGFloat
self.contentView.parentState = state
let contentViewSize = self.contentView.update(
transition: transition,
component: component.content,
environment: {
environment[ChildEnvironmentType.self]
},
containerSize: containerSize
)
component.externalState?.contentHeight = contentViewSize.height
if let contentView = self.contentView.view {
if contentView.superview == nil {
self.scrollContentView.addSubview(contentView)
}
transition.setFrame(view: contentView, frame: CGRect(origin: CGPoint(x: rawSideInset, y: 0.0), size: contentViewSize))
}
let contentHeight = contentViewSize.height
let initialContentHeight = contentHeight
let edgeEffectHeight: CGFloat = 80.0
let edgeEffectFrame = CGRect(origin: CGPoint(x: rawSideInset, y: 0.0), size: CGSize(width: fillingSize, height: edgeEffectHeight))
transition.setFrame(view: self.topEdgeEffectView, frame: edgeEffectFrame)
self.topEdgeEffectView.update(content: theme.actionSheet.opaqueItemBackgroundColor, blur: true, alpha: 1.0, rect: edgeEffectFrame, edge: .top, edgeSize: edgeEffectFrame.height, transition: transition)
if self.topEdgeEffectView.superview == nil {
self.navigationBarContainer.insertSubview(self.topEdgeEffectView, at: 0)
}
if let titleItem = component.titleItem {
let titleItemView: ComponentView<Empty>
if let current = self.titleItemView {
titleItemView = current
} else {
titleItemView = ComponentView<Empty>()
self.titleItemView = titleItemView
}
let titleItemSize = titleItemView.update(
transition: transition,
component: titleItem,
environment: {},
containerSize: CGSize(width: containerSize.width - 66.0 * 2.0, height: 66.0)
)
let titleItemFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((containerSize.width - titleItemSize.width)) / 2.0, y: floorToScreenPixels(36.0 - titleItemSize.height * 0.5)), size: titleItemSize)
if let view = titleItemView.view {
if view.superview == nil {
self.navigationBarContainer.addSubview(view)
}
transition.setFrame(view: view, frame: titleItemFrame)
}
} else if let titleItemView = self.titleItemView {
self.titleItemView = nil
titleItemView.view?.removeFromSuperview()
}
if let leftItem = component.leftItem {
let leftItemView: ComponentView<Empty>
if let current = self.leftItemView {
leftItemView = current
} else {
leftItemView = ComponentView<Empty>()
self.leftItemView = leftItemView
}
let leftItemSize = leftItemView.update(
transition: transition,
component: leftItem,
environment: {},
containerSize: CGSize(width: 66.0, height: 66.0)
)
let leftItemFrame = CGRect(origin: CGPoint(x: 16.0, y: 16.0), size: leftItemSize)
if let view = leftItemView.view {
if view.superview == nil {
self.navigationBarContainer.addSubview(view)
}
transition.setFrame(view: view, frame: leftItemFrame)
}
} else if let leftItemView = self.leftItemView {
self.leftItemView = nil
leftItemView.view?.removeFromSuperview()
}
if let rightItem = component.rightItem {
let rightItemView: ComponentView<Empty>
if let current = self.rightItemView {
rightItemView = current
} else {
rightItemView = ComponentView<Empty>()
self.rightItemView = rightItemView
}
let rightItemSize = rightItemView.update(
transition: transition,
component: rightItem,
environment: {},
containerSize: CGSize(width: 66.0, height: 66.0)
)
let rightItemFrame = CGRect(origin: CGPoint(x: containerSize.width - 16.0 - rightItemSize.width, y: 16.0), size: rightItemSize)
if let view = rightItemView.view {
if view.superview == nil {
self.navigationBarContainer.addSubview(view)
}
transition.setFrame(view: view, frame: rightItemFrame)
}
} else if let rightItemView = self.rightItemView {
self.rightItemView = nil
rightItemView.view?.removeFromSuperview()
}
clippingY = availableSize.height
let topInset: CGFloat = max(0.0, availableSize.height - containerInset - initialContentHeight)
let scrollContentHeight = max(topInset + contentHeight + containerInset, availableSize.height - containerInset)
self.scrollContentClippingView.layer.cornerRadius = 38.0
self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, containerCornerRadius: sheetEnvironment.deviceMetrics.screenCornerRadius, bottomInset: sheetEnvironment.safeInsets.bottom, topInset: topInset)
transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight)))
transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0))
transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: CGSize(width: fillingSize, height: availableSize.height)))
let scrollClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: containerInset), size: CGSize(width: availableSize.width, height: clippingY - containerInset))
transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center)
transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size))
self.ignoreScrolling = true
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height)))
let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight)
if contentSize != self.scrollView.contentSize {
self.scrollView.contentSize = contentSize
}
if resetScrolling {
self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize)
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
transition.setPosition(view: self.containerView, position: CGRect(origin: CGPoint(), size: availableSize).center)
transition.setBounds(view: self.containerView, bounds: CGRect(origin: CGPoint(), size: availableSize))
if sheetEnvironment.isDisplaying && !self.didPlayAppearanceAnimation {
self.animateIn()
}
return availableSize
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}