mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-07-16 16:21:11 +00:00
2166 lines
96 KiB
Swift
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)
|
|
}
|
|
}
|