2025-06-24 13:02:37 +02:00

2166 lines
96 KiB
Swift

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 BundleIconComponent
import ButtonComponent
import ItemListUI
import AccountContext
import PresentationDataUtils
import ListSectionComponent
import TelegramStringFormatting
import UndoUI
import ListActionItemComponent
import ChatScheduleTimeController
import TabSelectorComponent
private let amountTag = GenericComponentViewTag()
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let mode: StarsWithdrawScreen.Mode
let controller: () -> ViewController?
let dismiss: () -> Void
init(
context: AccountContext,
mode: StarsWithdrawScreen.Mode,
controller: @escaping () -> ViewController?,
dismiss: @escaping () -> Void
) {
self.context = context
self.mode = mode
self.controller = controller
self.dismiss = dismiss
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
return true
}
static var body: (CombinedComponentContext<SheetContent>) -> CGSize {
let closeButton = Child(Button.self)
let balance = Child(BalanceComponent.self)
let title = Child(Text.self)
let currencyToggle = Child(TabSelectorComponent.self)
let amountSection = Child(ListSectionComponent.self)
let amountAdditionalLabel = Child(MultilineTextComponent.self)
let timestampSection = Child(ListSectionComponent.self)
let button = Child(ButtonComponent.self)
let balanceTitle = Child(MultilineTextComponent.self)
let balanceValue = Child(MultilineTextComponent.self)
let balanceIcon = Child(BundleIconComponent.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
var contentSize = CGSize(width: context.availableSize.width, height: 18.0)
let constrainedTitleWidth = context.availableSize.width - 16.0 * 2.0
if case let .suggestedPost(mode, _, _, _) = component.mode {
switch mode {
case .sender:
let balance = balance.update(
component: BalanceComponent(
context: component.context,
theme: environment.theme,
strings: environment.strings,
currency: state.currency,
balance: state.currency == .stars ? state.starsBalance : state.tonBalance,
alignment: .right
),
availableSize: CGSize(width: 200.0, height: 200.0),
transition: .immediate
)
let balanceFrame = CGRect(origin: CGPoint(x: context.availableSize.width - balance.size.width - 15.0, y: floor((56.0 - balance.size.height) * 0.5)), size: balance.size)
context.add(balance
.anchorPoint(CGPoint(x: 1.0, y: 0.0))
.position(CGPoint(x: balanceFrame.maxX, y: balanceFrame.minY))
)
case .admin:
break
}
let closeButton = closeButton.update(
component: Button(
content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: environment.theme.list.itemAccentColor)),
action: {
component.dismiss()
}
).minSize(CGSize(width: 8.0, height: 44.0)),
availableSize: CGSize(width: 200.0, height: 100.0),
transition: .immediate
)
let closeFrame = CGRect(origin: CGPoint(x: 16.0, y: floor((56.0 - closeButton.size.height) * 0.5)), size: closeButton.size)
context.add(closeButton
.position(closeFrame.center)
)
} else {
let closeImage: UIImage
if let (image, theme) = state.cachedCloseImage, theme === environment.theme {
closeImage = image
} else {
closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)!
state.cachedCloseImage = (closeImage, theme)
}
let closeButton = closeButton.update(
component: Button(
content: AnyComponent(Image(image: closeImage)),
action: {
component.dismiss()
}
),
availableSize: CGSize(width: 30.0, height: 30.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: context.availableSize.width - closeButton.size.width, y: 28.0))
)
}
let titleString: String
let amountTitle: String
let amountPlaceholder: String
var amountLabel: String?
var amountRightLabel: String?
let minAmount: StarsAmount?
let maxAmount: StarsAmount?
let withdrawConfiguration = StarsWithdrawConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
let resaleConfiguration = StarsSubscriptionConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
switch component.mode {
case let .withdraw(status, _):
titleString = environment.strings.Stars_Withdraw_Title
amountTitle = environment.strings.Stars_Withdraw_AmountTitle
amountPlaceholder = environment.strings.Stars_Withdraw_AmountPlaceholder
minAmount = withdrawConfiguration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) }
maxAmount = status.balances.availableBalance
case .accountWithdraw:
titleString = environment.strings.Stars_Withdraw_Title
amountTitle = environment.strings.Stars_Withdraw_AmountTitle
amountPlaceholder = environment.strings.Stars_Withdraw_AmountPlaceholder
minAmount = withdrawConfiguration.minWithdrawAmount.flatMap { StarsAmount(value: $0, nanos: 0) }
maxAmount = state.starsBalance
case .paidMedia:
titleString = environment.strings.Stars_PaidContent_Title
amountTitle = environment.strings.Stars_PaidContent_AmountTitle
amountPlaceholder = environment.strings.Stars_PaidContent_AmountPlaceholder
minAmount = StarsAmount(value: 1, nanos: 0)
maxAmount = withdrawConfiguration.maxPaidMediaAmount.flatMap { StarsAmount(value: $0, nanos: 0) }
if let usdWithdrawRate = withdrawConfiguration.usdWithdrawRate, let amount = state.amount, amount > StarsAmount.zero {
let usdRate = Double(usdWithdrawRate) / 1000.0 / 100.0
amountLabel = "\(formatTonUsdValue(amount.value, divide: false, rate: usdRate, dateTimeFormat: environment.dateTimeFormat))"
}
case .reaction:
titleString = environment.strings.Stars_SendStars_Title
amountTitle = environment.strings.Stars_SendStars_AmountTitle
amountPlaceholder = environment.strings.Stars_SendStars_AmountPlaceholder
minAmount = StarsAmount(value: 1, nanos: 0)
maxAmount = withdrawConfiguration.maxPaidMediaAmount.flatMap { StarsAmount(value: $0, nanos: 0) }
case let .starGiftResell(_, update, _):
titleString = update ? environment.strings.Stars_SellGift_EditTitle : environment.strings.Stars_SellGift_Title
amountTitle = environment.strings.Stars_SellGift_AmountTitle
amountPlaceholder = environment.strings.Stars_SellGift_AmountPlaceholder
minAmount = StarsAmount(value: resaleConfiguration.starGiftResaleMinAmount, nanos: 0)
maxAmount = StarsAmount(value: resaleConfiguration.starGiftResaleMaxAmount, nanos: 0)
case let .paidMessages(_, minAmountValue, _, _, _):
titleString = environment.strings.Stars_SendMessage_AdjustmentTitle
amountTitle = environment.strings.Stars_SendMessage_AdjustmentSectionHeader
amountPlaceholder = environment.strings.Stars_SendMessage_AdjustmentPlaceholder
minAmount = StarsAmount(value: minAmountValue, nanos: 0)
maxAmount = StarsAmount(value: resaleConfiguration.paidMessageMaxAmount, nanos: 0)
case let .suggestedPost(mode, _, _, _):
//TODO:localize
switch mode {
case .sender:
titleString = "Suggest Terms"
case .admin:
titleString = "Suggest Changes"
}
switch state.currency {
case .stars:
amountTitle = "ENTER A PRICE IN STARS"
case .ton:
amountTitle = "ENTER A PRICE IN TON"
}
amountPlaceholder = "Price"
minAmount = StarsAmount(value: 0, nanos: 0)
//TODO:release
maxAmount = StarsAmount(value: resaleConfiguration.paidMessageMaxAmount, nanos: 0)
}
let title = title.update(
component: Text(text: titleString, font: Font.bold(17.0), color: theme.list.itemPrimaryTextColor),
availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
contentSize.height += 40.0
let balance: StarsAmount?
if case .accountWithdraw = component.mode {
balance = state.starsBalance
} else if case .reaction = component.mode {
balance = state.starsBalance
} else if case let .withdraw(starsState, _) = component.mode {
balance = starsState.balances.availableBalance
} else {
balance = nil
}
if let balance {
let balanceTitle = balanceTitle.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.Stars_Transfer_Balance,
font: Font.regular(14.0),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
),
availableSize: context.availableSize,
transition: .immediate
)
let balanceValue = balanceValue.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationStringsFormattedNumber(balance, environment.dateTimeFormat.groupingSeparator),
font: Font.semibold(16.0),
textColor: theme.list.itemPrimaryTextColor
)),
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 topBalanceOriginY = 11.0
context.add(balanceTitle
.position(CGPoint(x: 16.0 + environment.safeInsets.left + balanceTitle.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height / 2.0))
)
context.add(balanceIcon
.position(CGPoint(x: 16.0 + environment.safeInsets.left + balanceIcon.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height + balanceValue.size.height / 2.0 + 1.0 + UIScreenPixel))
)
context.add(balanceValue
.position(CGPoint(x: 16.0 + environment.safeInsets.left + balanceIcon.size.width + 3.0 + balanceValue.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height + balanceValue.size.height / 2.0 + 2.0 - UIScreenPixel))
)
}
if case let .suggestedPost(mode, _, _, _) = component.mode {
//TODO:localize
let selectedId: AnyHashable = state.currency == .stars ? AnyHashable(0 as Int) : AnyHashable(1 as Int)
let starsTitle: String
let tonTitle: String
switch mode {
case .sender:
starsTitle = "Offer Stars"
tonTitle = "Offer TON"
case .admin:
starsTitle = "Request Stars"
tonTitle = "Request TON"
}
let currencyToggle = currencyToggle.update(
component: TabSelectorComponent(
colors: TabSelectorComponent.Colors(
foreground: theme.list.itemSecondaryTextColor,
selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15),
simple: true
),
customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0),
spacing: 10.0
),
items: [
TabSelectorComponent.Item(
id: AnyHashable(0),
content: .component(AnyComponent(CurrencyTabItemComponent(icon: .stars, title: starsTitle, theme: theme)))
),
TabSelectorComponent.Item(
id: AnyHashable(1),
content: .component(AnyComponent(CurrencyTabItemComponent(icon: .ton, title: tonTitle, theme: theme)))
)
],
selectedId: selectedId,
setSelectedId: { [weak state] id in
guard let state else {
return
}
let currency: CurrencyAmount.Currency
if id == AnyHashable(0) {
currency = .stars
} else {
currency = .ton
}
if state.currency != currency {
state.currency = currency
state.amount = nil
}
state.updated(transition: .spring(duration: 0.4))
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 100.0),
transition: context.transition
)
contentSize.height -= 17.0
let currencyToggleFrame = CGRect(origin: CGPoint(x: floor((context.availableSize.width - currencyToggle.size.width) * 0.5), y: contentSize.height), size: currencyToggle.size)
context.add(currencyToggle
.position(currencyToggle.size.centered(in: currencyToggleFrame).center))
contentSize.height += currencyToggle.size.height + 29.0
}
let amountFont = Font.regular(13.0)
let boldAmountFont = Font.semibold(13.0)
let amountTextColor = theme.list.freeTextColor
let amountMarkdownAttributes = MarkdownAttributes(
body: MarkdownAttributeSet(font: amountFont, textColor: amountTextColor),
bold: MarkdownAttributeSet(font: boldAmountFont, textColor: amountTextColor),
link: MarkdownAttributeSet(font: amountFont, textColor: theme.list.itemAccentColor),
linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
}
)
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 amountFooter: AnyComponent<Empty>?
switch component.mode {
case .paidMedia:
let amountInfoString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_PaidContent_AmountInfo, attributes: amountMarkdownAttributes, textAlignment: .natural))
if let range = amountInfoString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 {
amountInfoString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: amountInfoString.string))
}
amountFooter = AnyComponent(MultilineTextComponent(
text: .plain(amountInfoString),
maximumNumberOfLines: 0,
highlightColor: environment.theme.list.itemAccentColor.withAlphaComponent(0.1),
highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0),
highlightAction: { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
} else {
return nil
}
},
tapAction: { attributes, _ in
if let controller = controller() as? StarsWithdrawScreen, let navigationController = controller.navigationController as? NavigationController {
component.context.sharedContext.openExternalUrl(context: component.context, urlContext: .generic, url: strings.Stars_PaidContent_AmountInfo_URL, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {})
}
}
))
case let .reaction(starsToTop, _):
let amountInfoString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_SendStars_AmountInfo("\(starsToTop ?? 0)").string, attributes: amountMarkdownAttributes, textAlignment: .natural))
amountFooter = AnyComponent(MultilineTextComponent(
text: .plain(amountInfoString),
maximumNumberOfLines: 0
))
case .starGiftResell:
let amountInfoString: NSAttributedString
if let value = state.amount?.value, value > 0 {
let starsValue = Int32(floor(Float(value) * Float(resaleConfiguration.starGiftCommissionPermille) / 1000.0))
let starsString = environment.strings.Stars_SellGift_AmountInfo_Stars(starsValue)
amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_SellGift_AmountInfo(starsString).string, attributes: amountMarkdownAttributes, textAlignment: .natural))
if let usdWithdrawRate = withdrawConfiguration.usdWithdrawRate {
let usdRate = Double(usdWithdrawRate) / 1000.0 / 100.0
amountRightLabel = "\(formatTonUsdValue(Int64(starsValue), divide: false, rate: usdRate, dateTimeFormat: environment.dateTimeFormat))"
}
} else {
amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_SellGift_AmountInfo("\(resaleConfiguration.starGiftCommissionPermille / 10)%").string, attributes: amountMarkdownAttributes, textAlignment: .natural))
}
amountFooter = AnyComponent(MultilineTextComponent(
text: .plain(amountInfoString),
maximumNumberOfLines: 0
))
case let .paidMessages(_, _, fractionAfterCommission, _, _):
let amountInfoString: NSAttributedString
if let value = state.amount?.value, value > 0 {
let fullValue: Int64 = Int64(value) * 1_000_000_000 * Int64(fractionAfterCommission) / 100
let amountValue = StarsAmount(value: fullValue / 1_000_000_000, nanos: Int32(fullValue % 1_000_000_000))
amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_SendMessage_AdjustmentSectionFooterValue("\(amountValue)").string, attributes: amountMarkdownAttributes, textAlignment: .natural))
} else {
amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.Stars_SendMessage_AdjustmentSectionFooterEmptyValue("\(fractionAfterCommission)").string, attributes: amountMarkdownAttributes, textAlignment: .natural))
}
amountFooter = AnyComponent(MultilineTextComponent(
text: .plain(amountInfoString),
maximumNumberOfLines: 0
))
case let .suggestedPost(mode, _, _, _):
switch mode {
case let .sender(channel):
//TODO:localize
let string: String
switch state.currency {
case .stars:
string = "Choose how many Stars you want to offer \(channel.compactDisplayTitle) to publish this message."
case .ton:
string = "Choose how many TON you want to offer \(channel.compactDisplayTitle) to publish this message."
}
let amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(string, attributes: amountMarkdownAttributes, textAlignment: .natural))
amountFooter = AnyComponent(MultilineTextComponent(
text: .plain(amountInfoString),
maximumNumberOfLines: 0
))
case .admin:
//TODO:localize
let string: String
switch state.currency {
case .stars:
string = "Choose how many Stars you charge for the message."
case .ton:
string = "Choose how many TON you charge for the message."
}
let amountInfoString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(string, attributes: amountMarkdownAttributes, textAlignment: .natural))
amountFooter = AnyComponent(MultilineTextComponent(
text: .plain(amountInfoString),
maximumNumberOfLines: 0
))
}
default:
amountFooter = nil
}
let amountSection = amountSection.update(
component: ListSectionComponent(
theme: theme,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: amountTitle.uppercased(),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
footer: amountFooter,
items: [
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: minAmount?.value,
maxValue: state.currency == .ton ? nil : maxAmount?.value,
placeholderText: amountPlaceholder,
labelText: amountLabel,
currency: state.currency,
dateTimeFormat: presentationData.dateTimeFormat,
amountUpdated: { [weak state] amount in
state?.amount = amount.flatMap { StarsAmount(value: $0, nanos: 0) }
state?.updated()
},
tag: amountTag
)
)
)
]
),
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
if let amountRightLabel {
let amountAdditionalLabel = amountAdditionalLabel.update(
component: MultilineTextComponent(text: .plain(NSAttributedString(string: amountRightLabel, font: amountFont, textColor: amountTextColor))),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude),
transition: context.transition
)
context.add(amountAdditionalLabel
.position(CGPoint(x: context.availableSize.width - amountAdditionalLabel.size.width / 2.0 - sideInset - 16.0, y: contentSize.height - amountAdditionalLabel.size.height / 2.0)))
}
if case let .suggestedPost(mode, _, _, _) = component.mode {
contentSize.height += 24.0
//TODO:localize
let footerString: String
switch mode {
case .sender:
footerString = "Select the date and time you want your message to be published."
case .admin:
footerString = "Select the date and time you want to publish the message."
}
let timestampFooterString = NSAttributedString(attributedString: parseMarkdownIntoAttributedString(footerString, attributes: amountMarkdownAttributes, textAlignment: .natural))
let timestampFooter = AnyComponent(MultilineTextComponent(
text: .plain(timestampFooterString),
maximumNumberOfLines: 0
))
let timeString: String
if let timestamp = state.timestamp {
timeString = humanReadableStringForTimestamp(strings: strings, dateTimeFormat: presentationData.dateTimeFormat, timestamp: timestamp, alwaysShowTime: true, allowYesterday: true, format: HumanReadableStringFormat(
dateFormatString: { value in
return PresentationStrings.FormattedString(string: strings.SuggestPost_SetTimeFormat_Date(value).string, ranges: [])
},
tomorrowFormatString: { value in
return PresentationStrings.FormattedString(string: strings.SuggestPost_SetTimeFormat_TomorrowAt(value).string, ranges: [])
},
todayFormatString: { value in
return PresentationStrings.FormattedString(string: strings.SuggestPost_SetTimeFormat_TodayAt(value).string, ranges: [])
},
yesterdayFormatString: { value in
return PresentationStrings.FormattedString(string: strings.SuggestPost_SetTimeFormat_TodayAt(value).string, ranges: [])
}
)).string
} else {
timeString = "Anytime"
}
let timestampSection = timestampSection.update(
component: ListSectionComponent(
theme: theme,
header: nil,
footer: timestampFooter,
items: [AnyComponentWithIdentity(
id: "timestamp",
component: AnyComponent(ListActionItemComponent(
theme: theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "Time",
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
], alignment: .left, spacing: 2.0)),
icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: timeString,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: environment.theme.list.itemSecondaryTextColor
)),
maximumNumberOfLines: 1
)))),
accessory: .arrow,
action: { [weak state] _ in
guard let state else {
return
}
let component = state.component
let theme = environment.theme
let controller = ChatScheduleTimeController(context: state.context, updatedPresentationData: (state.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), state.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }), mode: .suggestPost(needsTime: false), style: .default, currentTime: state.timestamp, minimalTime: nil, dismissByTapOutside: true, completion: { [weak state] time in
guard let state else {
return
}
state.timestamp = time == 0 ? nil : time
state.updated(transition: .immediate)
})
component.controller()?.view.endEditing(true)
component.controller()?.present(controller, in: .window(.root))
}
))
)]
),
environment: {},
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude),
transition: context.transition
)
context.add(timestampSection
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + timestampSection.size.height / 2.0))
.clipsToBounds(true)
.cornerRadius(10.0)
)
contentSize.height += timestampSection.size.height
}
contentSize.height += 32.0
let buttonString: String
if case .paidMedia = component.mode {
buttonString = environment.strings.Stars_PaidContent_Create
} else if case .starGiftResell = component.mode {
if let amount = state.amount, amount.value > 0 {
buttonString = "\(environment.strings.Stars_SellGift_SellFor) # \(presentationStringsFormattedNumber(amount, environment.dateTimeFormat.groupingSeparator))"
} else {
buttonString = environment.strings.Stars_SellGift_Sell
}
} else if case .paidMessages = component.mode {
buttonString = environment.strings.Stars_SendMessage_AdjustmentAction
} else if case let .suggestedPost(mode, _, _, _) = component.mode {
//TODO:localize
switch mode {
case .sender:
if let amount = state.amount {
let currencySymbol: String
let currencyAmount: String
switch state.currency {
case .stars:
currencySymbol = "#"
currencyAmount = presentationStringsFormattedNumber(amount, environment.dateTimeFormat.groupingSeparator)
case .ton:
currencySymbol = "$"
currencyAmount = formatTonAmountText(amount.value, dateTimeFormat: environment.dateTimeFormat)
}
buttonString = "Offer \(currencySymbol) \(currencyAmount)"
} else {
buttonString = "Offer for Free"
}
case .admin:
buttonString = "Update Terms"
}
} else if let amount = state.amount {
buttonString = "\(environment.strings.Stars_Withdraw_Withdraw) # \(presentationStringsFormattedNumber(amount, environment.dateTimeFormat.groupingSeparator))"
} else {
buttonString = environment.strings.Stars_Withdraw_Withdraw
}
if state.cachedStarImage == nil || state.cachedStarImage?.1 !== theme {
state.cachedStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: theme.list.itemCheckColors.foregroundColor)!, theme)
}
if state.cachedTonImage == nil || state.cachedTonImage?.1 !== theme {
state.cachedTonImage = (generateTintedImage(image: UIImage(bundleImageName: "Ads/TonAbout"), color: theme.list.itemCheckColors.foregroundColor)!, theme)
}
let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)
if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 {
buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string))
buttonAttributedString.addAttribute(.foregroundColor, value: theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string))
buttonAttributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: buttonAttributedString.string))
buttonAttributedString.addAttribute(.kern, value: 2.0, range: NSRange(range, in: buttonAttributedString.string))
}
if let range = buttonAttributedString.string.range(of: "$"), let tonImage = state.cachedTonImage?.0 {
buttonAttributedString.addAttribute(.attachment, value: tonImage, range: NSRange(range, in: buttonAttributedString.string))
buttonAttributedString.addAttribute(.foregroundColor, value: theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string))
buttonAttributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: buttonAttributedString.string))
buttonAttributedString.addAttribute(.kern, value: 2.0, range: NSRange(range, in: buttonAttributedString.string))
}
var isButtonEnabled = false
let amount = state.amount ?? StarsAmount.zero
if amount > StarsAmount.zero {
isButtonEnabled = true
} else if case let .paidMessages(_, minValue, _, _, _) = context.component.mode {
if minValue <= 0 {
isButtonEnabled = true
}
} else if case .suggestedPost = context.component.mode {
isButtonEnabled = true
}
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 10.0
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString)))
),
isEnabled: isButtonEnabled,
displaysProgress: false,
action: { [weak state] in
if let controller = controller() as? StarsWithdrawScreen, let state {
let amount = state.amount ?? StarsAmount.zero
if let minAmount, amount < minAmount {
controller.presentMinAmountTooltip(minAmount.value)
} else {
switch state.mode {
case let .withdraw(_, completion):
completion(amount.value)
case let .accountWithdraw(completion):
completion(amount.value)
case let .paidMedia(_, completion):
completion(amount.value)
case let .reaction(_, completion):
completion(amount.value)
case let .starGiftResell(_, _, completion):
completion(amount.value)
case let .paidMessages(_, _, _, _, completion):
completion(amount.value)
case let .suggestedPost(_, _, _, completion):
completion(CurrencyAmount(amount: amount, currency: state.currency), state.timestamp)
}
controller.dismissAnimated()
}
}
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50),
transition: .immediate
)
context.add(button
.clipsToBounds(true)
.cornerRadius(10.0)
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
)
contentSize.height += button.size.height
contentSize.height += 15.0
contentSize.height += max(environment.inputHeight, environment.safeInsets.bottom)
return contentSize
}
return body
}
final class State: ComponentState {
fileprivate let context: AccountContext
fileprivate let mode: StarsWithdrawScreen.Mode
fileprivate var component: SheetContent
fileprivate var amount: StarsAmount?
fileprivate var currency: CurrencyAmount.Currency = .stars
fileprivate var timestamp: Int32?
fileprivate var starsBalance: StarsAmount?
private var starsStateDisposable: Disposable?
fileprivate var tonBalance: StarsAmount?
private var tonStateDisposable: Disposable?
var cachedCloseImage: (UIImage, PresentationTheme)?
var cachedStarImage: (UIImage, PresentationTheme)?
var cachedTonImage: (UIImage, PresentationTheme)?
var cachedChevronImage: (UIImage, PresentationTheme)?
init(component: SheetContent) {
self.context = component.context
self.mode = component.mode
self.component = component
var amount: StarsAmount?
var currency: CurrencyAmount.Currency = .stars
switch mode {
case let .withdraw(stats, _):
amount = StarsAmount(value: stats.balances.availableBalance.value, nanos: 0)
case .accountWithdraw:
amount = context.starsContext?.currentState.flatMap { StarsAmount(value: $0.balance.value, nanos: 0) }
case let .paidMedia(initialValue, _):
amount = initialValue.flatMap { StarsAmount(value: $0, nanos: 0) }
case .reaction:
amount = nil
case .starGiftResell:
amount = nil
case let .paidMessages(initialValue, _, _, _, _):
amount = StarsAmount(value: initialValue, nanos: 0)
case let .suggestedPost(_, initialValue, initialTimestamp, _):
currency = initialValue.currency
amount = initialValue.amount
self.timestamp = initialTimestamp
}
self.currency = currency
self.amount = amount
super.init()
var needsBalance = false
switch self.mode {
case .reaction:
needsBalance = true
case let .suggestedPost(mode, _, _, _):
switch mode {
case .sender:
needsBalance = true
case .admin:
break
}
default:
break
}
if needsBalance {
if let starsContext = component.context.starsContext {
self.starsStateDisposable = (starsContext.state
|> deliverOnMainQueue).startStrict(next: { [weak self] state in
if let self, let balance = state?.balance {
self.starsBalance = balance
self.updated()
}
})
}
if let tonContext = component.context.tonContext {
self.tonStateDisposable = (tonContext.state
|> deliverOnMainQueue).startStrict(next: { [weak self] state in
if let self, let balance = state?.balance {
self.tonBalance = balance
self.updated()
}
})
}
}
if case let .starGiftResell(giftToMatch, update, _) = self.mode {
if update {
if let resellStars = giftToMatch.resellStars {
self.amount = StarsAmount(value: resellStars, nanos: 0)
}
} else {
let _ = (context.engine.payments.cachedStarGifts()
|> filter { $0 != nil }
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] gifts in
guard let self, let gifts else {
return
}
guard let matchingGift = gifts.first(where: { gift in
if case let .generic(gift) = gift, gift.title == giftToMatch.title {
return true
} else {
return false
}
}) else {
return
}
if case let .generic(genericGift) = matchingGift, let minResaleStars = genericGift.availability?.minResaleStars {
self.amount = StarsAmount(value: minResaleStars, nanos: 0)
self.updated()
}
})
}
}
}
deinit {
self.starsStateDisposable?.dispose()
self.tonStateDisposable?.dispose()
}
}
func makeState() -> State {
return State(component: self)
}
}
private final class StarsWithdrawSheetComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
private let context: AccountContext
private let mode: StarsWithdrawScreen.Mode
init(
context: AccountContext,
mode: StarsWithdrawScreen.Mode
) {
self.context = context
self.mode = mode
}
static func ==(lhs: StarsWithdrawSheetComponent, rhs: StarsWithdrawSheetComponent) -> Bool {
return true
}
static var body: Body {
let sheet = Child(SheetComponent<(EnvironmentType)>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
mode: context.component.mode,
controller: {
return controller()
},
dismiss: {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
}
)),
backgroundColor: .color(environment.theme.list.blocksBackgroundColor),
followContentSizeChanges: false,
clipsContent: true,
isScrollEnabled: false,
animateOut: animateOut
),
environment: {
environment
SheetComponentEnvironment(
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { 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)
}
}
}
)
},
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 StarsWithdrawScreen: ViewControllerComponentContainer {
public enum Mode {
public enum SuggestedPostMode {
case sender(channel: EnginePeer)
case admin
}
case withdraw(StarsRevenueStats, completion: (Int64) -> Void)
case accountWithdraw(completion: (Int64) -> Void)
case paidMedia(Int64?, completion: (Int64) -> Void)
case reaction(Int64?, completion: (Int64) -> Void)
case starGiftResell(StarGift.UniqueGift, Bool, completion: (Int64) -> Void)
case paidMessages(current: Int64, minValue: Int64, fractionAfterCommission: Int, kind: StarsWithdrawalScreenSubject.PaidMessageKind, completion: (Int64) -> Void)
case suggestedPost(mode: SuggestedPostMode, price: CurrencyAmount, timestamp: Int32?, completion: (CurrencyAmount, Int32?) -> Void)
}
private let context: AccountContext
private let mode: StarsWithdrawScreen.Mode
public init(
context: AccountContext,
mode: StarsWithdrawScreen.Mode
) {
self.context = context
self.mode = mode
super.init(
context: context,
component: StarsWithdrawSheetComponent(
context: context,
mode: mode
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let view = self.node.hostView.findTaggedView(tag: amountTag) as? AmountFieldComponent.View {
Queue.mainQueue().after(0.01) {
view.activateInput()
view.selectAll()
}
}
}
func presentMinAmountTooltip(_ minAmount: Int64) {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
var text = presentationData.strings.Stars_Withdraw_Withdraw_ErrorMinimum(presentationData.strings.Stars_Withdraw_Withdraw_ErrorMinimum_Stars(Int32(minAmount))).string
if case .starGiftResell = self.mode {
//TODO:localize
text = "You cannot sell gift for less than \(presentationData.strings.Stars_Withdraw_Withdraw_ErrorMinimum_Stars(Int32(minAmount)))."
}
let resultController = UndoOverlayController(
presentationData: presentationData,
content: .image(
image: UIImage(bundleImageName: "Premium/Stars/StarLarge")!,
title: nil,
text: text,
round: false,
undoText: nil
),
elevatedLayout: false,
position: .top,
action: { _ in return true})
self.present(resultController, in: .window(.root))
if let view = self.node.hostView.findTaggedView(tag: amountTag) as? AmountFieldComponent.View {
view.animateError()
}
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}
private let invalidAmountCharacters = CharacterSet.decimalDigits.inverted
private final class AmountFieldTonFormatter: NSObject, UITextFieldDelegate {
private struct Representation {
private let format: CurrencyFormat
private var caretIndex: Int = 0
private var wholePart: [Int] = []
private var decimalPart: [Int] = []
init(string: String, format: CurrencyFormat) {
self.format = format
var isDecimalPart = false
for c in string {
if c.isNumber {
if let value = Int(String(c)) {
if isDecimalPart {
self.decimalPart.append(value)
} else {
self.wholePart.append(value)
}
}
} else if String(c) == format.decimalSeparator {
isDecimalPart = true
}
}
while self.wholePart.count > 1 {
if self.wholePart[0] != 0 {
break
} else {
self.wholePart.removeFirst()
}
}
if self.wholePart.isEmpty {
self.wholePart = [0]
}
while self.decimalPart.count > 1 {
if self.decimalPart[self.decimalPart.count - 1] != 0 {
break
} else {
self.decimalPart.removeLast()
}
}
while self.decimalPart.count < format.decimalDigits {
self.decimalPart.append(0)
}
self.caretIndex = self.wholePart.count
}
var minCaretIndex: Int {
for i in 0 ..< self.wholePart.count {
if self.wholePart[i] != 0 {
return i
}
}
return self.wholePart.count
}
mutating func moveCaret(offset: Int) {
self.caretIndex = max(self.minCaretIndex, min(self.caretIndex + offset, self.wholePart.count + self.decimalPart.count))
}
mutating func normalize() {
while self.wholePart.count > 1 {
if self.wholePart[0] != 0 {
break
} else {
self.wholePart.removeFirst()
self.moveCaret(offset: -1)
}
}
if self.wholePart.isEmpty {
self.wholePart = [0]
}
while self.decimalPart.count < format.decimalDigits {
self.decimalPart.append(0)
}
while self.decimalPart.count > format.decimalDigits {
self.decimalPart.removeLast()
}
self.caretIndex = max(self.minCaretIndex, min(self.caretIndex, self.wholePart.count + self.decimalPart.count))
}
mutating func backspace() {
if self.caretIndex > self.wholePart.count {
let decimalIndex = self.caretIndex - self.wholePart.count
if decimalIndex > 0 {
self.decimalPart.remove(at: decimalIndex - 1)
self.moveCaret(offset: -1)
self.normalize()
}
} else {
if self.caretIndex > 0 {
self.wholePart.remove(at: self.caretIndex - 1)
self.moveCaret(offset: -1)
self.normalize()
}
}
}
mutating func insert(letter: String) {
if letter == "." || letter == "," {
if self.caretIndex == self.wholePart.count {
return
} else if self.caretIndex < self.wholePart.count {
for i in (self.caretIndex ..< self.wholePart.count).reversed() {
self.decimalPart.insert(self.wholePart[i], at: 0)
self.wholePart.remove(at: i)
}
}
self.normalize()
} else if letter.count == 1 && letter[letter.startIndex].isNumber {
if let value = Int(letter) {
if self.caretIndex <= self.wholePart.count {
self.wholePart.insert(value, at: self.caretIndex)
} else {
let decimalIndex = self.caretIndex - self.wholePart.count
self.decimalPart.insert(value, at: decimalIndex)
}
self.moveCaret(offset: 1)
self.normalize()
}
}
}
var string: String {
var result = ""
for digit in self.wholePart {
result.append("\(digit)")
}
result.append(self.format.decimalSeparator)
for digit in self.decimalPart {
result.append("\(digit)")
}
return result
}
var stringCaretIndex: Int {
var logicalIndex = 0
var resolvedIndex = 0
if logicalIndex == self.caretIndex {
return resolvedIndex
}
for _ in self.wholePart {
logicalIndex += 1
resolvedIndex += 1
if logicalIndex == self.caretIndex {
return resolvedIndex
}
}
resolvedIndex += 1
for _ in self.decimalPart {
logicalIndex += 1
resolvedIndex += 1
if logicalIndex == self.caretIndex {
return resolvedIndex
}
}
return resolvedIndex
}
var numericalValue: Int64 {
var result: Int64 = 0
for digit in self.wholePart {
result *= 10
result += Int64(digit)
}
for digit in self.decimalPart {
result *= 10
result += Int64(digit)
}
return result
}
}
private let format: CurrencyFormat
private let currency: String
private let maxNumericalValue: Int64
private let updated: (Int64) -> Void
private let isEmptyUpdated: (Bool) -> Void
private let focusUpdated: (Bool) -> Void
private var representation: Representation
private var previousResolvedCaretIndex: Int = 0
private var ignoreTextSelection: Bool = false
private var enableTextSelectionProcessing: Bool = false
init?(textField: UITextField, currency: String, maxNumericalValue: Int64, initialValue: String, updated: @escaping (Int64) -> Void, isEmptyUpdated: @escaping (Bool) -> Void, focusUpdated: @escaping (Bool) -> Void) {
guard let format = CurrencyFormat(currency: currency) else {
return nil
}
self.format = format
self.currency = currency
self.maxNumericalValue = maxNumericalValue
self.updated = updated
self.isEmptyUpdated = isEmptyUpdated
self.focusUpdated = focusUpdated
self.representation = Representation(string: initialValue, format: format)
super.init()
textField.text = self.representation.string
self.previousResolvedCaretIndex = self.representation.stringCaretIndex
self.isEmptyUpdated(false)
}
func reset(textField: UITextField, initialValue: String) {
self.representation = Representation(string: initialValue, format: self.format)
self.resetFromRepresentation(textField: textField, notifyUpdated: false)
}
private func resetFromRepresentation(textField: UITextField, notifyUpdated: Bool) {
self.ignoreTextSelection = true
if self.representation.numericalValue > self.maxNumericalValue {
self.representation = Representation(string: formatCurrencyAmountCustom(self.maxNumericalValue, currency: self.currency).0, format: self.format)
}
textField.text = self.representation.string
self.previousResolvedCaretIndex = self.representation.stringCaretIndex
if self.enableTextSelectionProcessing {
let stringCaretIndex = self.representation.stringCaretIndex
if let caretPosition = textField.position(from: textField.beginningOfDocument, offset: stringCaretIndex) {
textField.selectedTextRange = textField.textRange(from: caretPosition, to: caretPosition)
}
}
self.ignoreTextSelection = false
if notifyUpdated {
self.updated(self.representation.numericalValue)
}
}
@objc public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if string.count == 1 {
self.representation.insert(letter: string)
self.resetFromRepresentation(textField: textField, notifyUpdated: true)
} else if string.count == 0 {
self.representation.backspace()
self.resetFromRepresentation(textField: textField, notifyUpdated: true)
}
return false
}
@objc public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
return false
}
@objc public func textFieldDidBeginEditing(_ textField: UITextField) {
self.enableTextSelectionProcessing = true
self.focusUpdated(true)
let stringCaretIndex = self.representation.stringCaretIndex
self.previousResolvedCaretIndex = stringCaretIndex
if let caretPosition = textField.position(from: textField.beginningOfDocument, offset: stringCaretIndex) {
self.ignoreTextSelection = true
textField.selectedTextRange = textField.textRange(from: caretPosition, to: caretPosition)
DispatchQueue.main.async {
textField.selectedTextRange = textField.textRange(from: caretPosition, to: caretPosition)
self.ignoreTextSelection = false
}
}
}
@objc public func textFieldDidChangeSelection(_ textField: UITextField) {
if self.ignoreTextSelection {
return
}
if !self.enableTextSelectionProcessing {
return
}
if let selectedTextRange = textField.selectedTextRange {
let index = textField.offset(from: textField.beginningOfDocument, to: selectedTextRange.end)
if self.previousResolvedCaretIndex != index {
self.representation.moveCaret(offset: self.previousResolvedCaretIndex < index ? 1 : -1)
let stringCaretIndex = self.representation.stringCaretIndex
self.previousResolvedCaretIndex = stringCaretIndex
if let caretPosition = textField.position(from: textField.beginningOfDocument, offset: stringCaretIndex) {
textField.selectedTextRange = textField.textRange(from: caretPosition, to: caretPosition)
}
}
}
}
@objc public func textFieldDidEndEditing(_ textField: UITextField) {
self.enableTextSelectionProcessing = false
self.focusUpdated(false)
}
}
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 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, 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.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 dot = text.firstIndex(of: ".") {
// 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) {
amount = whole * scale + frac
}
} else if let whole = Int64(text) { // string had no dot at all
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 self.minValue <= 0 {
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 c == "." {
return false
}
}
return true
}
}) {
return false
}
if newText.count(where: { $0 == "." }) > 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:
if (newText == "0" && !acceptZero) || (newText.count > 1 && newText.hasPrefix("0") && !newText.hasPrefix("0.")) {
newText.removeFirst()
textField.text = newText
self.onTextChanged(text: newText)
return false
}
}
let amount: Int64 = self.amountFrom(text: newText)
if amount > self.maxValue {
switch self.currency {
case .stars:
textField.text = "\(self.maxValue)"
case .ton:
textField.text = "\(formatTonAmountText(self.maxValue, dateTimeFormat: self.dateTimeFormat))"
}
self.onTextChanged(text: self.textField.text ?? "")
self.animateError()
return false
}
self.onTextChanged(text: newText)
return true
}
}
private final class AmountFieldComponent: Component {
typealias EnvironmentType = Empty
let textColor: UIColor
let secondaryColor: UIColor
let placeholderColor: UIColor
let accentColor: UIColor
let value: Int64?
let minValue: Int64?
let maxValue: Int64?
let placeholderText: String
let labelText: String?
let currency: CurrencyAmount.Currency
let dateTimeFormat: PresentationDateTimeFormat
let amountUpdated: (Int64?) -> Void
let tag: AnyObject?
init(
textColor: UIColor,
secondaryColor: UIColor,
placeholderColor: UIColor,
accentColor: UIColor,
value: Int64?,
minValue: Int64?,
maxValue: Int64?,
placeholderText: String,
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.maxValue = maxValue
self.placeholderText = placeholderText
self.labelText = labelText
self.currency = currency
self.dateTimeFormat = dateTimeFormat
self.amountUpdated = amountUpdated
self.tag = tag
}
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.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
}
final class View: UIView, 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
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")
}
func activateInput() {
self.textField.becomeFirstResponder()
}
func selectAll() {
self.textField.selectAll(nil)
}
func animateError() {
self.textField.layer.addShakeAnimation()
let hapticFeedback = HapticFeedback()
hapticFeedback.error()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0, execute: {
let _ = hapticFeedback
})
}
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 {
if let value = component.value {
var text = ""
switch component.currency {
case .stars:
text = "\(value)"
case .ton:
text = "\(formatTonAmountText(value, dateTimeFormat: component.dateTimeFormat))"
}
self.textField.text = text
} else {
self.textField.text = ""
}
}
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,
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
self.textField.text = ""
case .ton:
self.textField.keyboardType = .numbersAndPunctuation
if self.tonFormatter == nil {
self.tonFormatter = AmountFieldStarsFormatter(
textField: self.textField,
currency: component.currency,
dateTimeFormat: component.dateTimeFormat,
minValue: component.minValue ?? 0,
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: 44.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, y: 0.0, 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)
}
}
func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? {
return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(backgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
context.setLineWidth(2.0)
context.setLineCap(.round)
context.setStrokeColor(foregroundColor.cgColor)
context.move(to: CGPoint(x: 10.0, y: 10.0))
context.addLine(to: CGPoint(x: 20.0, y: 20.0))
context.strokePath()
context.move(to: CGPoint(x: 20.0, y: 10.0))
context.addLine(to: CGPoint(x: 10.0, y: 20.0))
context.strokePath()
})
}
private struct StarsWithdrawConfiguration {
static var defaultValue: StarsWithdrawConfiguration {
return StarsWithdrawConfiguration(minWithdrawAmount: nil, maxPaidMediaAmount: nil, usdWithdrawRate: nil)
}
let minWithdrawAmount: Int64?
let maxPaidMediaAmount: Int64?
let usdWithdrawRate: Double?
fileprivate init(minWithdrawAmount: Int64?, maxPaidMediaAmount: Int64?, usdWithdrawRate: Double?) {
self.minWithdrawAmount = minWithdrawAmount
self.maxPaidMediaAmount = maxPaidMediaAmount
self.usdWithdrawRate = usdWithdrawRate
}
static func with(appConfiguration: AppConfiguration) -> StarsWithdrawConfiguration {
if let data = appConfiguration.data {
var minWithdrawAmount: Int64?
if let value = data["stars_revenue_withdrawal_min"] as? Double {
minWithdrawAmount = Int64(value)
}
var maxPaidMediaAmount: Int64?
if let value = data["stars_paid_post_amount_max"] as? Double {
maxPaidMediaAmount = Int64(value)
}
var usdWithdrawRate: Double?
if let value = data["stars_usd_withdraw_rate_x1000"] as? Double {
usdWithdrawRate = value
}
return StarsWithdrawConfiguration(minWithdrawAmount: minWithdrawAmount, maxPaidMediaAmount: maxPaidMediaAmount, usdWithdrawRate: usdWithdrawRate)
} else {
return .defaultValue
}
}
}
private final class BalanceComponent: CombinedComponent {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let currency: CurrencyAmount.Currency
let balance: StarsAmount?
let alignment: NSTextAlignment
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
currency: CurrencyAmount.Currency,
balance: StarsAmount?,
alignment: NSTextAlignment
) {
self.context = context
self.theme = theme
self.strings = strings
self.currency = currency
self.balance = balance
self.alignment = alignment
}
static func ==(lhs: BalanceComponent, rhs: BalanceComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.currency != rhs.currency {
return false
}
if lhs.balance != rhs.balance {
return false
}
if lhs.alignment != rhs.alignment {
return false
}
return true
}
static var body: Body {
let title = Child(MultilineTextComponent.self)
let balance = Child(MultilineTextComponent.self)
let icon = Child(BundleIconComponent.self)
return { context in
var size = CGSize(width: 0.0, height: 0.0)
let title = title.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: context.component.strings.SendStarReactions_Balance, font: Font.regular(14.0), textColor: context.component.theme.list.itemPrimaryTextColor))
),
availableSize: context.availableSize,
transition: .immediate
)
size.width = max(size.width, title.size.width)
size.height += title.size.height
let balanceText: String
if let value = context.component.balance {
switch context.component.currency {
case .stars:
balanceText = "\(value.stringValue)"
case .ton:
let dateTimeFormat = context.component.context.sharedContext.currentPresentationData.with({ $0 }).dateTimeFormat
balanceText = "\(formatTonAmountText(value.value, dateTimeFormat: dateTimeFormat))"
}
} else {
balanceText = "..."
}
let balance = balance.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: balanceText, font: Font.medium(15.0), textColor: context.component.theme.list.itemPrimaryTextColor))
),
availableSize: context.availableSize,
transition: .immediate
)
let iconSize: CGSize
let iconName: String
var iconOffset = CGPoint()
var iconTintColor: UIColor?
switch context.component.currency {
case .stars:
iconSize = CGSize(width: 18.0, height: 18.0)
iconName = "Premium/Stars/StarLarge"
case .ton:
iconSize = CGSize(width: 13.0, height: 13.0)
iconName = "Ads/TonBig"
iconTintColor = context.component.theme.list.itemAccentColor
iconOffset = CGPoint(x: 0.0, y: 2.33)
}
let icon = icon.update(
component: BundleIconComponent(
name: iconName,
tintColor: iconTintColor
),
availableSize: iconSize,
transition: context.transition
)
let titleSpacing: CGFloat = 1.0
let iconSpacing: CGFloat = 2.0
size.height += titleSpacing
size.width = max(size.width, icon.size.width + iconSpacing + balance.size.width)
size.height += balance.size.height
if context.component.alignment == .right {
context.add(
title.position(
title.size.centered(in: CGRect(origin: CGPoint(x: size.width - title.size.width, y: 0.0), size: title.size)).center
)
)
context.add(
balance.position(
balance.size.centered(in: CGRect(origin: CGPoint(x: size.width - balance.size.width, y: title.size.height + titleSpacing), size: balance.size)).center
)
)
context.add(
icon.position(
icon.size.centered(in: CGRect(origin: CGPoint(x: iconOffset.x + size.width - balance.size.width - icon.size.width - 1.0, y: iconOffset.y + title.size.height + titleSpacing), size: icon.size)).center
)
)
} else {
context.add(
title.position(
title.size.centered(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: title.size)).center
)
)
context.add(
balance.position(
balance.size.centered(in: CGRect(origin: CGPoint(x: icon.size.width + iconSpacing, y: title.size.height + titleSpacing), size: balance.size)).center
)
)
context.add(
icon.position(
icon.size.centered(in: CGRect(origin: CGPoint(x: -1.0, y: title.size.height + titleSpacing), size: icon.size)).center
)
)
}
return size
}
}
}
private final class CurrencyTabItemComponent: Component {
typealias EnvironmentType = TabSelectorComponent.ItemEnvironment
enum Icon {
case stars
case ton
}
let icon: Icon
let title: String
let theme: PresentationTheme
init(
icon: Icon,
title: String,
theme: PresentationTheme
) {
self.icon = icon
self.title = title
self.theme = theme
}
static func ==(lhs: CurrencyTabItemComponent, rhs: CurrencyTabItemComponent) -> Bool {
if lhs.icon != rhs.icon {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private let title = ComponentView<Empty>()
private let icon = ComponentView<Empty>()
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: CurrencyTabItemComponent, availableSize: CGSize, state: State, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
let iconSpacing: CGFloat = 4.0
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: component.icon == .stars ? "Premium/Stars/StarLarge" : "Ads/TonAbout",
tintColor: component.icon == .stars ? nil : component.theme.list.itemAccentColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.medium(14.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: iconSize.width + iconSpacing, y: 0.0), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
titleView.frame = titleFrame
transition.setTintColor(layer: titleView.layer, color: component.theme.list.freeTextColor.mixedWith(component.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.5), alpha: environment[TabSelectorComponent.ItemEnvironment.self].value.selectionFraction))
}
let iconFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((titleSize.height - iconSize.height) * 0.5)), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
iconView.frame = iconFrame
}
return CGSize(width: iconSize.width + iconSpacing + titleSize.width, height: titleSize.height)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: State, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}