Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,164 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import PassKit
import ShimmerEffect
enum BotCheckoutActionButtonState: Equatable {
case active(text: String, isEnabled: Bool)
case applePay(isEnabled: Bool)
case placeholder
}
private let titleFont = Font.semibold(17.0)
final class BotCheckoutActionButton: HighlightTrackingButtonNode {
static var height: CGFloat = 52.0
private var activeFillColor: UIColor
private var inactiveFillColor: UIColor
private var foregroundColor: UIColor
private let activeBackgroundNode: ASDisplayNode
private var applePayButton: UIButton?
private let labelNode: TextNode
private var state: BotCheckoutActionButtonState?
private var validLayout: (CGRect, CGSize)?
private var placeholderNode: ShimmerEffectNode?
init(activeFillColor: UIColor, inactiveFillColor: UIColor, foregroundColor: UIColor) {
self.activeFillColor = activeFillColor
self.inactiveFillColor = inactiveFillColor
self.foregroundColor = foregroundColor
let diameter: CGFloat = 52.0
self.activeBackgroundNode = ASDisplayNode()
self.activeBackgroundNode.isLayerBacked = true
self.activeBackgroundNode.backgroundColor = activeFillColor
self.activeBackgroundNode.cornerRadius = diameter / 2.0
self.labelNode = TextNode()
self.labelNode.displaysAsynchronously = false
self.labelNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.activeBackgroundNode)
self.addSubnode(self.labelNode)
self.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
let transition = ContainedViewLayoutTransition.animated(duration: highlighted ? 0.25 : 0.35, curve: .spring)
if highlighted {
let highlightedColor = self.activeFillColor.withMultiplied(hue: 1.0, saturation: 0.77, brightness: 1.01)
transition.updateBackgroundColor(node: self.activeBackgroundNode, color: highlightedColor)
transition.updateTransformScale(node: self, scale: 1.05)
} else {
transition.updateBackgroundColor(node: self.activeBackgroundNode, color: self.activeFillColor)
transition.updateTransformScale(node: self, scale: 1.0)
}
}
}
func setState(_ state: BotCheckoutActionButtonState) {
if self.state != state {
let previousState = self.state
self.state = state
if let (absoluteRect, containerSize) = self.validLayout, let _ = previousState {
self.updateLayout(absoluteRect: absoluteRect, containerSize: containerSize, transition: .immediate)
}
}
}
@objc private func applePayButtonPressed() {
self.sendActions(forControlEvents: .touchUpInside, with: nil)
}
func updateLayout(absoluteRect: CGRect, containerSize: CGSize, transition: ContainedViewLayoutTransition) {
let size = absoluteRect.size
self.validLayout = (absoluteRect, containerSize)
transition.updateFrame(node: self.activeBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: BotCheckoutActionButton.height)))
var labelSize = self.labelNode.bounds.size
if let state = self.state {
switch state {
case let .active(title, isEnabled):
if let applePayButton = self.applePayButton {
self.applePayButton = nil
applePayButton.removeFromSuperview()
}
if let placeholderNode = self.placeholderNode {
self.placeholderNode = nil
placeholderNode.removeFromSupernode()
}
self.activeBackgroundNode.backgroundColor = isEnabled ? self.activeFillColor : self.inactiveFillColor
let makeLayout = TextNode.asyncLayout(self.labelNode)
let (labelLayout, labelApply) = makeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: title, font: titleFont, textColor: self.foregroundColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: size, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let _ = labelApply()
labelSize = labelLayout.size
case let .applePay(isEnabled):
if self.applePayButton == nil {
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
let applePayButton: PKPaymentButton
if #available(iOS 14.0, *) {
applePayButton = PKPaymentButton(paymentButtonType: .buy, paymentButtonStyle: .black)
} else {
applePayButton = PKPaymentButton(paymentButtonType: .buy, paymentButtonStyle: .black)
}
applePayButton.addTarget(self, action: #selector(self.applePayButtonPressed), for: .touchUpInside)
self.view.addSubview(applePayButton)
self.applePayButton = applePayButton
applePayButton.isEnabled = isEnabled
}
}
if let placeholderNode = self.placeholderNode {
self.placeholderNode = nil
placeholderNode.removeFromSupernode()
}
if let applePayButton = self.applePayButton {
applePayButton.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: BotCheckoutActionButton.height))
}
case .placeholder:
if let applePayButton = self.applePayButton {
self.applePayButton = nil
applePayButton.removeFromSuperview()
}
let contentSize = CGSize(width: 80.0, height: 8.0)
let shimmerNode: ShimmerEffectNode
if let current = self.placeholderNode {
shimmerNode = current
} else {
shimmerNode = ShimmerEffectNode()
self.placeholderNode = shimmerNode
self.addSubnode(shimmerNode)
}
shimmerNode.frame = CGRect(origin: CGPoint(x: floor((size.width - contentSize.width) / 2.0), y: floor((size.height - contentSize.height) / 2.0)), size: contentSize)
shimmerNode.updateAbsoluteRect(CGRect(origin: CGPoint(x: absoluteRect.minX + shimmerNode.frame.minX, y: absoluteRect.minY + shimmerNode.frame.minY), size: contentSize), within: containerSize)
var shapes: [ShimmerEffectNode.Shape] = []
shapes.append(.roundedRectLine(startPoint: CGPoint(x: 0.0, y: 0.0), width: contentSize.width, diameter: contentSize.height))
shimmerNode.update(backgroundColor: self.activeFillColor, foregroundColor: self.activeFillColor.mixedWith(UIColor.white, alpha: 0.25), shimmeringColor: self.activeFillColor.mixedWith(UIColor.white, alpha: 0.15), shapes: shapes, size: contentSize)
}
}
transition.updateFrame(node: self.labelNode, frame: CGRect(origin: CGPoint(x: floor((size.width - labelSize.width) / 2.0), y: floor((size.height - labelSize.height) / 2.0)), size: labelSize))
}
}
@@ -0,0 +1,247 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
public final class BotCheckoutController: ViewController {
public final class InputData {
public enum FetchError {
case generic
case disallowedStarGifts
case starGiftsUserLimit
}
public let form: BotPaymentForm
public let validatedFormInfo: BotPaymentValidatedFormInfo?
public let botPeer: EnginePeer?
public init(
form: BotPaymentForm,
validatedFormInfo: BotPaymentValidatedFormInfo?,
botPeer: EnginePeer?
) {
self.form = form
self.validatedFormInfo = validatedFormInfo
self.botPeer = botPeer
}
public static func fetch(context: AccountContext, source: BotPaymentInvoiceSource) -> Signal<InputData, FetchError> {
let theme = context.sharedContext.currentPresentationData.with { $0 }.theme
let themeParams: [String: Any]?
switch source {
case .starGift, .starGiftUpgrade:
themeParams = nil
default:
themeParams = [
"bg_color": Int32(bitPattern: theme.list.plainBackgroundColor.rgb),
"secondary_bg_color": Int32(bitPattern: theme.list.blocksBackgroundColor.rgb),
"text_color": Int32(bitPattern: theme.list.itemPrimaryTextColor.rgb),
"hint_color": Int32(bitPattern: theme.list.itemSecondaryTextColor.rgb),
"link_color": Int32(bitPattern: theme.list.itemAccentColor.rgb),
"button_color": Int32(bitPattern: theme.list.itemCheckColors.fillColor.rgb),
"button_text_color": Int32(bitPattern: theme.list.itemCheckColors.foregroundColor.rgb),
"header_bg_color": Int32(bitPattern: theme.rootController.navigationBar.opaqueBackgroundColor.rgb),
"accent_text_color": Int32(bitPattern: theme.list.itemAccentColor.rgb),
"section_bg_color": Int32(bitPattern: theme.list.itemBlocksBackgroundColor.rgb),
"section_header_text_color": Int32(bitPattern: theme.list.freeTextColor.rgb),
"subtitle_text_color": Int32(bitPattern: theme.list.itemSecondaryTextColor.rgb),
"destructive_text_color": Int32(bitPattern: theme.list.itemDestructiveColor.rgb),
"section_separator_color": Int32(bitPattern: theme.list.itemBlocksSeparatorColor.rgb)
]
}
return context.engine.payments.fetchBotPaymentForm(source: source, themeParams: themeParams)
|> mapError { error -> FetchError in
switch error {
case .disallowedStarGift:
return .disallowedStarGifts
case .starGiftUserLimit:
return .starGiftsUserLimit
default:
return .generic
}
}
|> mapToSignal { paymentForm -> Signal<InputData, FetchError> in
let botPeer: Signal<EnginePeer?, FetchError>
if let paymentBotId = paymentForm.paymentBotId {
botPeer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: paymentBotId))
|> castError(FetchError.self)
} else {
botPeer = .single(nil)
}
return botPeer
|> mapToSignal { botPeer -> Signal<InputData, FetchError> in
if let current = paymentForm.savedInfo {
return context.engine.payments.validateBotPaymentForm(saveInfo: true, source: source, formInfo: current)
|> mapError { _ -> FetchError in
return .generic
}
|> map { result -> InputData in
return InputData(
form: paymentForm,
validatedFormInfo: result,
botPeer: botPeer
)
}
|> `catch` { _ -> Signal<InputData, FetchError> in
return .single(InputData(
form: paymentForm,
validatedFormInfo: nil,
botPeer: botPeer
))
}
} else {
return .single(InputData(
form: paymentForm,
validatedFormInfo: nil,
botPeer: botPeer
))
}
}
}
}
}
private var controllerNode: BotCheckoutControllerNode {
return self.displayNode as! BotCheckoutControllerNode
}
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private let context: AccountContext
private let invoice: TelegramMediaInvoice
private let source: BotPaymentInvoiceSource
private let completed: (String, EngineMessage.Id?) -> Void
private let pending: () -> Void
private let cancelled: () -> Void
private let failed: () -> Void
private var presentationData: PresentationData
private var didPlayPresentationAnimation = false
private let inputData: Promise<BotCheckoutController.InputData?>
public init(context: AccountContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, inputData: Promise<BotCheckoutController.InputData?>, completed: @escaping (String, EngineMessage.Id?) -> Void, pending: @escaping () -> Void = {}, cancelled: @escaping () -> Void = {}, failed: @escaping () -> Void = {}) {
self.context = context
self.invoice = invoice
self.source = source
self.inputData = inputData
self.completed = completed
self.pending = pending
self.cancelled = cancelled
self.failed = failed
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
var title = self.presentationData.strings.Checkout_Title
if invoice.flags.contains(.isTest) {
title += " (Test)"
}
self.title = title
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func loadDisplayNode() {
let displayNode = BotCheckoutControllerNode(controller: self, navigationBar: self.navigationBar!, context: self.context, invoice: self.invoice, source: self.source, inputData: self.inputData, present: { [weak self] c, a in
self?.present(c, in: .window(.root), with: a)
}, dismissAnimated: { [weak self] in
self?.dismiss()
}, completed: { [weak self] currencyValue, receiptMessageId in
self?.complete(currencyValue: currencyValue, receiptMessageId: receiptMessageId)
})
displayNode.dismiss = { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
displayNode.pending = { [weak self] in
self?.setPending()
}
displayNode.failed = { [weak self] in
self?.fail()
}
self.displayNode = displayNode
super.displayNodeDidLoad()
self._ready.set(displayNode.ready)
}
override public func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.cancel()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments, !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true
if case .modalSheet = presentationArguments.presentationAnimation {
self.controllerNode.animateIn()
}
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition, additionalInsets: UIEdgeInsets())
}
private var didCancel = false
private func cancel() {
guard !self.didCancel && !self.didFail && !self.didComplete else {
return
}
self.didCancel = true
self.cancelled()
}
private var didFail = false
private func fail() {
guard !self.didCancel && !self.didFail && !self.didComplete else {
return
}
self.didFail = true
self.failed()
}
private var didComplete = false
private func complete(currencyValue: String, receiptMessageId: EngineMessage.Id?) {
guard !self.didCancel && !self.didFail && !self.didComplete else {
return
}
self.didComplete = true
self.completed(currencyValue, receiptMessageId)
}
private var isPending = false
private func setPending() {
guard !self.isPending && !self.didCancel && !self.didFail && !self.didComplete else {
return
}
self.pending()
}
@objc private func cancelPressed() {
self.cancel()
self.dismiss()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,341 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import PhotoResources
class BotCheckoutHeaderItem: ListViewItem, ItemListItem {
let account: Account
let theme: PresentationTheme
let invoice: TelegramMediaInvoice
let source: BotPaymentInvoiceSource
let botName: String
let sectionId: ItemListSectionId
init(account: Account, theme: PresentationTheme, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, botName: String, sectionId: ItemListSectionId) {
self.account = account
self.theme = theme
self.invoice = invoice
self.source = source
self.botName = botName
self.sectionId = sectionId
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = BotCheckoutHeaderItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? BotCheckoutHeaderItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
let selectable: Bool = false
}
private let titleFont = Font.semibold(16.0)
private let textFont = Font.regular(14.0)
class BotCheckoutHeaderItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
private let imageNode: TransformImageNode
private let titleNode: TextNode
private let textNode: TextNode
private let botNameNode: TextNode
private var item: BotCheckoutHeaderItem?
private let fetchDisposable = MetaDisposable()
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.imageNode = TransformImageNode()
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentMode = .left
self.titleNode.contentsScale = UIScreen.main.scale
self.textNode = TextNode()
self.textNode.isUserInteractionEnabled = false
self.textNode.contentMode = .left
self.textNode.contentsScale = UIScreen.main.scale
self.botNameNode = TextNode()
self.botNameNode.isUserInteractionEnabled = false
self.botNameNode.contentMode = .left
self.botNameNode.contentsScale = UIScreen.main.scale
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.imageNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.botNameNode)
}
deinit {
self.fetchDisposable.dispose()
}
func asyncLayout() -> (_ item: BotCheckoutHeaderItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeTextLayout = TextNode.asyncLayout(self.textNode)
let makeBotNameLayout = TextNode.asyncLayout(self.botNameNode)
let makeImageLayout = self.imageNode.asyncLayout()
let currentItem = self.item
return { item, params, neighbors in
var updatedTheme: PresentationTheme?
if currentItem?.theme !== item.theme {
updatedTheme = item.theme
}
let previousPhoto = currentItem?.invoice.photo
var imageUpdated = false
if let previousPhoto = previousPhoto, let photo = item.invoice.photo {
if !previousPhoto.isEqual(to: photo) {
imageUpdated = true
}
} else if (previousPhoto != nil) != (item.invoice.photo != nil) {
imageUpdated = true
}
let textColor = item.theme.list.itemPrimaryTextColor
let contentInsets = UIEdgeInsets(top: 15.0, left: 15.0 + params.leftInset, bottom: 15.0, right: 15.0 + params.rightInset)
let separatorHeight = UIScreenPixel
let titleTextSpacing: CGFloat = 1.0
let textBotNameSpacing: CGFloat = 3.0
let imageTextSpacing: CGFloat = 15.0
let imageSize = CGSize(width: 134.0, height: 134.0)
let maxTextHeight = imageSize.height
var maxTextWidth = params.width - contentInsets.left - contentInsets.right
var imageApply: (() -> Void)?
var updatedImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
var updatedFetchSignal: Signal<Never, NoError>?
if let photo = item.invoice.photo, let dimensions = photo.dimensions {
let arguments = TransformImageArguments(corners: ImageCorners(radius: 4.0), imageSize: dimensions.cgSize.aspectFilled(imageSize), boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.theme.list.mediaPlaceholderColor)
imageApply = makeImageLayout(arguments)
maxTextWidth = max(1.0, maxTextWidth - imageSize.width - imageTextSpacing)
if imageUpdated {
updatedImageSignal = chatWebFileImage(account: item.account, file: photo)
var userLocation: MediaResourceUserLocation = .other
switch item.source {
case let .message(messageId):
userLocation = .peer(messageId.peerId)
default:
break
}
updatedFetchSignal = fetchedMediaResource(mediaBox: item.account.postbox.mediaBox, userLocation: userLocation, userContentType: .image, reference: .standalone(resource: photo.resource))
|> ignoreValues
|> `catch` { _ -> Signal<Never, NoError> in
return .complete()
}
}
}
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.invoice.title, font: titleFont, textColor: textColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (botNameLayout, botNameApply) = makeBotNameLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.botName, font: textFont, textColor: item.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let textLayoutMaxHeight: CGFloat = maxTextHeight - titleLayout.size.height - titleTextSpacing - botNameLayout.size.height - textBotNameSpacing
let textArguments = TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.invoice.description, font: textFont, textColor: textColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: maxTextWidth, height: textLayoutMaxHeight), alignment: .natural, cutout: nil, insets: UIEdgeInsets())
let (textLayout, textApply) = makeTextLayout(textArguments)
let contentHeight: CGFloat
if let _ = imageApply {
contentHeight = contentInsets.top + contentInsets.bottom + imageSize.height
} else {
contentHeight = contentInsets.top + contentInsets.bottom + titleLayout.size.height + titleTextSpacing + textLayout.size.height + textBotNameSpacing + botNameLayout.size.height
}
let contentSize = CGSize(width: params.width, height: contentHeight)
let insets = itemListNeighborsPlainInsets(neighbors)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.theme.list.itemHighlightedBackgroundColor
}
let _ = titleApply()
let _ = textApply()
let _ = botNameApply()
if let imageApply = imageApply {
let _ = imageApply()
if let updatedImageSignal = updatedImageSignal {
strongSelf.imageNode.setSignal(updatedImageSignal)
}
if let updatedFetchSignal = updatedFetchSignal {
strongSelf.fetchDisposable.set(updatedFetchSignal.start())
}
strongSelf.imageNode.isHidden = false
} else {
strongSelf.imageNode.isHidden = true
}
strongSelf.imageNode.frame = CGRect(origin: CGPoint(x: contentInsets.left, y: contentInsets.top), size: imageSize)
/*if strongSelf.backgroundNode.supernode != nil {
strongSelf.backgroundNode.removeFromSupernode()
}*/
if strongSelf.topStripeNode.supernode != nil {
strongSelf.topStripeNode.removeFromSupernode()
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 0)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.addSubnode(strongSelf.maskNode)
}
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
switch neighbors.bottom {
case .sameSection(false):
strongSelf.bottomStripeNode.isHidden = false
default:
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: contentSize.height - separatorHeight), size: CGSize(width: params.width, height: separatorHeight))
var titleFrame = CGRect(origin: CGPoint(x: contentInsets.left, y: contentInsets.top), size: titleLayout.size)
if let _ = imageApply {
titleFrame.origin.x += imageSize.width + imageTextSpacing
}
strongSelf.titleNode.frame = titleFrame
let textFrame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleTextSpacing), size: textLayout.size)
strongSelf.textNode.frame = textFrame
strongSelf.botNameNode.frame = CGRect(origin: CGPoint(x: textFrame.minX, y: textFrame.maxY + textBotNameSpacing), size: botNameLayout.size)
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: params.width, height: contentSize.height))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: contentSize.height + UIScreenPixel + UIScreenPixel))
}
})
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
var anchorNode: ASDisplayNode?
if self.bottomStripeNode.supernode != nil {
anchorNode = self.bottomStripeNode
} else if self.topStripeNode.supernode != nil {
anchorNode = self.topStripeNode
} else if self.backgroundNode.supernode != nil {
anchorNode = self.backgroundNode
}
if let anchorNode = anchorNode {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode)
} else {
self.addSubnode(self.highlightedBackgroundNode)
}
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
}
@@ -0,0 +1,162 @@
import Foundation
import UIKit
import SwiftSignalKit
import Display
import TelegramCore
import TelegramPresentationData
import ProgressNavigationButtonNode
import AccountContext
import CountrySelectionUI
enum BotCheckoutInfoControllerAddressFocus {
case street1
case street2
case city
case state
case postcode
}
enum BotCheckoutInfoControllerFocus {
case address(BotCheckoutInfoControllerAddressFocus)
case name
case phone
case email
}
final class BotCheckoutInfoController: ViewController {
private var controllerNode: BotCheckoutInfoControllerNode {
return super.displayNode as! BotCheckoutInfoControllerNode
}
private let context: AccountContext
private let invoice: BotPaymentInvoice
private let source: BotPaymentInvoiceSource
private let initialFormInfo: BotPaymentRequestedInfo
private let focus: BotCheckoutInfoControllerFocus
private let formInfoUpdated: (BotPaymentRequestedInfo, BotPaymentValidatedFormInfo) -> Void
private var presentationData: PresentationData
private var didPlayPresentationAnimation = false
private var doneItem: UIBarButtonItem?
private var activityItem: UIBarButtonItem?
public init(
context: AccountContext,
invoice: BotPaymentInvoice,
source: BotPaymentInvoiceSource,
initialFormInfo: BotPaymentRequestedInfo,
focus: BotCheckoutInfoControllerFocus,
formInfoUpdated: @escaping (BotPaymentRequestedInfo, BotPaymentValidatedFormInfo) -> Void
) {
self.context = context
self.invoice = invoice
self.source = source
self.initialFormInfo = initialFormInfo
self.focus = focus
self.formInfoUpdated = formInfoUpdated
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.doneItem = UIBarButtonItem(title: self.presentationData.strings.Common_Done, style: .done, target: self, action: #selector(self.donePressed))
self.title = self.presentationData.strings.CheckoutInfo_Title
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
self.navigationItem.rightBarButtonItem = self.doneItem
self.doneItem?.isEnabled = false
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override public func loadDisplayNode() {
self.displayNode = BotCheckoutInfoControllerNode(context: self.context, navigationBar: self.navigationBar, invoice: self.invoice, source: self.source, formInfo: self.initialFormInfo, focus: self.focus, theme: self.presentationData.theme, strings: self.presentationData.strings, dismiss: { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}, openCountrySelection: { [weak self] in
if let strongSelf = self {
let controller = AuthorizationSequenceCountrySelectionController(strings: strongSelf.presentationData.strings, theme: strongSelf.presentationData.theme, displayCodes: false)
controller.completeWithCountryCode = { _, id in
if let strongSelf = self {
strongSelf.controllerNode.updateCountry(id)
}
}
strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}
}, updateStatus: { [weak self] status in
if let strongSelf = self {
switch status {
case .notReady:
strongSelf.doneItem?.isEnabled = false
case .ready:
strongSelf.doneItem?.isEnabled = true
case .verifying:
break
}
switch status {
case .verifying:
if strongSelf.activityItem == nil {
strongSelf.activityItem = UIBarButtonItem(customDisplayNode: ProgressNavigationButtonNode(color: strongSelf.presentationData.theme.rootController.navigationBar.controlColor))
strongSelf.navigationItem.setRightBarButton(strongSelf.activityItem, animated: false)
}
default:
if strongSelf.activityItem != nil {
strongSelf.activityItem = nil
strongSelf.navigationItem.setRightBarButton(strongSelf.doneItem, animated: false)
}
}
}
}, formInfoUpdated: { [weak self] formInfo, validatedInfo in
if let strongSelf = self {
strongSelf.formInfoUpdated(formInfo, validatedInfo)
strongSelf.dismiss()
}
}, present: { [weak self] c, a in
self?.present(c, in: .window(.root), with: a)
})
self.displayNodeDidLoad()
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments, !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true
if case .modalSheet = presentationArguments.presentationAnimation {
self.controllerNode.animateIn()
}
}
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
@objc func cancelPressed() {
self.dismiss()
}
@objc func donePressed() {
self.controllerNode.verify()
}
}
@@ -0,0 +1,575 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import AlertUI
import PresentationDataUtils
import CountrySelectionUI
private final class BotCheckoutInfoAddressItems {
let address1: BotPaymentFieldItemNode
let address2: BotPaymentFieldItemNode
let city: BotPaymentFieldItemNode
let state: BotPaymentFieldItemNode
let country: BotPaymentDisclosureItemNode
let postcode: BotPaymentFieldItemNode
var items: [BotPaymentItemNode] {
return [
self.address1,
self.address2,
self.city,
self.state,
self.country,
self.postcode
]
}
init(strings: PresentationStrings, openCountrySelection: @escaping () -> Void) {
self.address1 = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ShippingInfoAddress1, placeholder: strings.CheckoutInfo_ShippingInfoAddress1Placeholder, contentType: .address)
self.address2 = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ShippingInfoAddress2, placeholder: strings.CheckoutInfo_ShippingInfoAddress2Placeholder, contentType: .address)
self.city = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ShippingInfoCity, placeholder: strings.CheckoutInfo_ShippingInfoCityPlaceholder, contentType: .address)
self.state = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ShippingInfoState, placeholder: strings.CheckoutInfo_ShippingInfoStatePlaceholder, contentType: .address)
self.country = BotPaymentDisclosureItemNode(title: strings.CheckoutInfo_ShippingInfoCountry, placeholder: strings.CheckoutInfo_ShippingInfoCountryPlaceholder, text: "")
self.postcode = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ShippingInfoPostcode, placeholder: strings.CheckoutInfo_ShippingInfoPostcodePlaceholder, contentType: .address)
self.country.action = {
openCountrySelection()
}
}
}
private final class BotCheckoutInfoControllerScrollerNodeView: UIScrollView {
var ignoreUpdateBounds = false
override init(frame: CGRect) {
super.init(frame: frame)
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.contentInsetAdjustmentBehavior = .never
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var bounds: CGRect {
get {
return super.bounds
} set(value) {
if !self.ignoreUpdateBounds {
super.bounds = value
}
}
}
override func scrollRectToVisible(_ rect: CGRect, animated: Bool) {
}
}
private final class BotCheckoutInfoControllerScrollerNode: ASDisplayNode {
override var view: BotCheckoutInfoControllerScrollerNodeView {
return super.view as! BotCheckoutInfoControllerScrollerNodeView
}
override init() {
super.init()
self.setViewBlock({
return BotCheckoutInfoControllerScrollerNodeView(frame: CGRect())
})
}
}
enum BotCheckoutInfoControllerStatus {
case notReady
case ready
case verifying
}
final class BotCheckoutInfoControllerNode: ViewControllerTracingNode, ASScrollViewDelegate {
private let context: AccountContext
private weak var navigationBar: NavigationBar?
private let invoice: BotPaymentInvoice
private let source: BotPaymentInvoiceSource
private var focus: BotCheckoutInfoControllerFocus?
private let dismiss: () -> Void
private let openCountrySelection: () -> Void
private let updateStatus: (BotCheckoutInfoControllerStatus) -> Void
private let formInfoUpdated: (BotPaymentRequestedInfo, BotPaymentValidatedFormInfo) -> Void
private let present: (ViewController, Any?) -> Void
private var theme: PresentationTheme
private var strings: PresentationStrings
private var containerLayout: (ContainerViewLayout, CGFloat)?
private let scrollNode: BotCheckoutInfoControllerScrollerNode
private let itemNodes: [[BotPaymentItemNode]]
private let leftOverlayNode: ASDisplayNode
private let rightOverlayNode: ASDisplayNode
private let addressItems: BotCheckoutInfoAddressItems?
private let nameItem: BotPaymentFieldItemNode?
private let emailItem: BotPaymentFieldItemNode?
private let phoneItem: BotPaymentFieldItemNode?
private let saveInfoItem: BotPaymentSwitchItemNode
private var formInfo: BotPaymentRequestedInfo
private let verifyDisposable = MetaDisposable()
private var isVerifying = false
init(
context: AccountContext,
navigationBar: NavigationBar?,
invoice: BotPaymentInvoice,
source: BotPaymentInvoiceSource,
formInfo: BotPaymentRequestedInfo,
focus: BotCheckoutInfoControllerFocus,
theme: PresentationTheme,
strings: PresentationStrings,
dismiss: @escaping () -> Void,
openCountrySelection: @escaping () -> Void,
updateStatus: @escaping (BotCheckoutInfoControllerStatus) -> Void,
formInfoUpdated: @escaping (BotPaymentRequestedInfo, BotPaymentValidatedFormInfo) -> Void,
present: @escaping (ViewController, Any?) -> Void
) {
self.context = context
self.navigationBar = navigationBar
self.invoice = invoice
self.source = source
self.formInfo = formInfo
self.focus = focus
self.dismiss = dismiss
self.openCountrySelection = openCountrySelection
self.updateStatus = updateStatus
self.formInfoUpdated = formInfoUpdated
self.present = present
self.theme = theme
self.strings = strings
self.scrollNode = BotCheckoutInfoControllerScrollerNode()
self.leftOverlayNode = ASDisplayNode()
self.leftOverlayNode.isUserInteractionEnabled = false
self.rightOverlayNode = ASDisplayNode()
self.rightOverlayNode.isUserInteractionEnabled = false
var itemNodes: [[BotPaymentItemNode]] = []
var openCountrySelectionImpl: (() -> Void)?
if invoice.requestedFields.contains(.shippingAddress) {
var sectionItems: [BotPaymentItemNode] = []
let addressItems = BotCheckoutInfoAddressItems(strings: strings, openCountrySelection: { openCountrySelectionImpl?()
})
addressItems.address1.text = formInfo.shippingAddress?.streetLine1 ?? ""
addressItems.address2.text = formInfo.shippingAddress?.streetLine2 ?? ""
addressItems.city.text = formInfo.shippingAddress?.city ?? ""
addressItems.state.text = formInfo.shippingAddress?.state ?? ""
if let iso2 = formInfo.shippingAddress?.countryIso2, let name = AuthorizationSequenceCountrySelectionController.lookupCountryNameById(iso2.uppercased(), strings: self.strings) {
addressItems.country.text = name
}
addressItems.postcode.text = formInfo.shippingAddress?.postCode ?? ""
sectionItems.append(BotPaymentHeaderItemNode(text: strings.CheckoutInfo_ShippingInfoTitle))
sectionItems.append(contentsOf: addressItems.items)
itemNodes.append(sectionItems)
self.addressItems = addressItems
} else {
self.addressItems = nil
}
if !invoice.requestedFields.intersection([.name, .phone, .email]).isEmpty {
var sectionItems: [BotPaymentItemNode] = []
sectionItems.append(BotPaymentHeaderItemNode(text: strings.CheckoutInfo_ReceiverInfoTitle))
if invoice.requestedFields.contains(.name) {
let nameItem = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ReceiverInfoName, placeholder: strings.CheckoutInfo_ReceiverInfoNamePlaceholder, contentType: .name)
nameItem.text = formInfo.name ?? ""
self.nameItem = nameItem
sectionItems.append(nameItem)
} else {
self.nameItem = nil
}
if invoice.requestedFields.contains(.email) {
let emailItem = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ReceiverInfoEmail, placeholder: strings.CheckoutInfo_ReceiverInfoEmailPlaceholder, contentType: .email)
emailItem.text = formInfo.email ?? ""
self.emailItem = emailItem
sectionItems.append(emailItem)
} else {
self.emailItem = nil
}
if invoice.requestedFields.contains(.phone) {
let phoneItem = BotPaymentFieldItemNode(title: strings.CheckoutInfo_ReceiverInfoPhone, placeholder: strings.CheckoutInfo_ReceiverInfoPhone, contentType: .phoneNumber)
phoneItem.text = formInfo.phone ?? ""
self.phoneItem = phoneItem
sectionItems.append(phoneItem)
} else {
self.phoneItem = nil
}
itemNodes.append(sectionItems)
} else {
self.nameItem = nil
self.emailItem = nil
self.phoneItem = nil
}
self.saveInfoItem = BotPaymentSwitchItemNode(title: strings.CheckoutInfo_SaveInfo, isOn: true)
itemNodes.append([self.saveInfoItem, BotPaymentTextItemNode(text: strings.CheckoutInfo_SaveInfoHelp)])
self.itemNodes = itemNodes
for items in itemNodes {
for item in items {
self.scrollNode.addSubnode(item)
}
}
super.init()
self.backgroundColor = self.theme.list.blocksBackgroundColor
self.leftOverlayNode.backgroundColor = self.theme.list.blocksBackgroundColor
self.rightOverlayNode.backgroundColor = self.theme.list.blocksBackgroundColor
self.scrollNode.backgroundColor = nil
self.scrollNode.isOpaque = false
self.scrollNode.view.alwaysBounceVertical = true
self.scrollNode.view.showsVerticalScrollIndicator = false
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.delegate = self.wrappedScrollViewDelegate
self.addSubnode(self.scrollNode)
openCountrySelectionImpl = { [weak self] in
if let strongSelf = self {
strongSelf.view.endEditing(true)
strongSelf.openCountrySelection()
}
}
let fieldsAndTypes = { [weak self] () -> [(BotPaymentFieldItemNode, BotCheckoutInfoControllerFocus)] in
guard let strongSelf = self else {
return []
}
var fieldsAndTypes: [(BotPaymentFieldItemNode, BotCheckoutInfoControllerFocus)] = []
if let addressItems = strongSelf.addressItems {
fieldsAndTypes.append((addressItems.address1, .address(.street1)))
fieldsAndTypes.append((addressItems.address2, .address(.street2)))
fieldsAndTypes.append((addressItems.city, .address(.city)))
fieldsAndTypes.append((addressItems.state, .address(.state)))
fieldsAndTypes.append((addressItems.postcode, .address(.postcode)))
}
if let nameItem = strongSelf.nameItem {
fieldsAndTypes.append((nameItem, .name))
}
if let phoneItem = strongSelf.phoneItem {
fieldsAndTypes.append((phoneItem, .phone))
}
if let emailItem = strongSelf.emailItem {
fieldsAndTypes.append((emailItem, .email))
}
return fieldsAndTypes
}
for items in itemNodes {
for item in items {
if let item = item as? BotPaymentFieldItemNode {
item.focused = { [weak self, weak item] in
guard let strongSelf = self, let item = item else {
return
}
for (node, focus) in fieldsAndTypes() {
if node === item {
strongSelf.focus = focus
break
}
}
}
item.textUpdated = { [weak self] in
self?.updateDone()
}
item.returnPressed = { [weak self, weak item] in
guard let strongSelf = self, let item = item else {
return
}
var activateNext = false
outer: for section in strongSelf.itemNodes {
for i in 0 ..< section.count {
if section[i] === item {
activateNext = true
} else if activateNext, let field = section[i] as? BotPaymentFieldItemNode {
for (node, focus) in fieldsAndTypes() {
if node === field {
strongSelf.focus = focus
if let containerLayout = strongSelf.containerLayout {
strongSelf.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
break outer
}
}
}
}
}
}
}
}
}
self.updateDone()
}
deinit {
self.verifyDisposable.dispose()
}
override func didLoad() {
super.didLoad()
self.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate)
}
func updateCountry(_ iso2: String) {
if self.formInfo.shippingAddress?.countryIso2 != iso2, let name = AuthorizationSequenceCountrySelectionController.lookupCountryNameById(iso2, strings: self.strings) {
let shippingAddress: BotPaymentShippingAddress
if let current = self.formInfo.shippingAddress {
shippingAddress = current
} else {
shippingAddress = BotPaymentShippingAddress(streetLine1: "", streetLine2: "", city: "", state: "", countryIso2: iso2, postCode: "")
}
self.formInfo = BotPaymentRequestedInfo(name: self.formInfo.name, phone: self.formInfo.phone, email: self.formInfo.email, shippingAddress: BotPaymentShippingAddress(streetLine1: shippingAddress.streetLine1, streetLine2: shippingAddress.streetLine2, city: shippingAddress.city, state: shippingAddress.state, countryIso2: iso2, postCode: shippingAddress.postCode))
self.addressItems?.country.text = name
if let containerLayout = self.containerLayout {
self.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.updateDone()
}
}
private func collectFormInfo() -> BotPaymentRequestedInfo {
var address: BotPaymentShippingAddress?
if let addressItems = self.addressItems, let current = self.formInfo.shippingAddress {
address = BotPaymentShippingAddress(streetLine1: addressItems.address1.text, streetLine2: addressItems.address2.text, city: addressItems.city.text, state: addressItems.state.text, countryIso2: current.countryIso2, postCode: addressItems.postcode.text)
}
return BotPaymentRequestedInfo(name: self.nameItem?.text, phone: self.phoneItem?.text, email: self.emailItem?.text, shippingAddress: address)
}
func verify() {
self.isVerifying = true
let formInfo = self.collectFormInfo()
self.verifyDisposable.set((self.context.engine.payments.validateBotPaymentForm(saveInfo: self.saveInfoItem.isOn, source: self.source, formInfo: formInfo) |> deliverOnMainQueue).start(next: { [weak self] result in
if let strongSelf = self {
strongSelf.formInfoUpdated(formInfo, result)
}
}, error: { [weak self] error in
if let strongSelf = self {
strongSelf.isVerifying = false
strongSelf.updateDone()
let text: String
switch error {
case .shippingNotAvailable:
text = strongSelf.strings.CheckoutInfo_ErrorShippingNotAvailable
case .addressStateInvalid:
text = strongSelf.strings.CheckoutInfo_ErrorStateInvalid
case .addressPostcodeInvalid:
text = strongSelf.strings.CheckoutInfo_ErrorPostcodeInvalid
case .addressCityInvalid:
text = strongSelf.strings.CheckoutInfo_ErrorCityInvalid
case .nameInvalid:
text = strongSelf.strings.CheckoutInfo_ErrorNameInvalid
case .emailInvalid:
text = strongSelf.strings.CheckoutInfo_ErrorEmailInvalid
case .phoneInvalid:
text = strongSelf.strings.CheckoutInfo_ErrorPhoneInvalid
case .generic:
text = strongSelf.strings.Login_UnknownError
}
strongSelf.present(textAlertController(context: strongSelf.context, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.strings.Common_OK, action: {})]), nil)
}
}))
self.updateDone()
}
private func updateDone() {
var enabled = true
if let addressItems = self.addressItems {
if addressItems.address1.text.isEmpty {
enabled = false
}
if addressItems.city.text.isEmpty {
enabled = false
}
if let shippingAddress = self.formInfo.shippingAddress, shippingAddress.countryIso2.isEmpty {
enabled = false
}
if addressItems.postcode.text.isEmpty {
enabled = false
}
}
if let nameItem = self.nameItem, nameItem.text.isEmpty {
enabled = false
}
if let phoneItem = self.phoneItem, phoneItem.text.isEmpty {
enabled = false
}
if let emailItem = self.emailItem, emailItem.text.isEmpty {
enabled = false
}
if self.isVerifying {
self.updateStatus(.verifying)
} else if enabled {
self.updateStatus(.ready)
} else {
self.updateStatus(.notReady)
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let previousLayout = self.containerLayout
self.containerLayout = (layout, navigationBarHeight)
var insets = layout.insets(options: [.input])
insets.top += max(navigationBarHeight, layout.insets(options: [.statusBar]).top)
var contentHeight: CGFloat = 0.0
var commonInset: CGFloat = 0.0
for items in self.itemNodes {
for item in items {
commonInset = max(commonInset, item.measureInset(theme: self.theme, width: layout.size.width))
}
}
let inset = max(16.0, floor((layout.size.width - 674.0) / 2.0))
var sideInset: CGFloat = 0.0
if layout.size.width >= 375.0 {
sideInset = inset
}
for items in self.itemNodes {
if !items.isEmpty && items[0] is BotPaymentHeaderItemNode {
contentHeight += 24.0
} else {
contentHeight += 32.0
}
for i in 0 ..< items.count {
let item = items[i]
let itemHeight = item.updateLayout(theme: self.theme, width: layout.size.width, sideInset: sideInset, measuredInset: commonInset, previousItemNode: i == 0 ? nil : items[i - 1], nextItemNode: i == (items.count - 1) ? nil : items[i + 1], transition: transition)
transition.updateFrame(node: item, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: layout.size.width, height: itemHeight)))
contentHeight += itemHeight
}
}
contentHeight += 24.0
let scrollContentSize = CGSize(width: layout.size.width, height: contentHeight)
let previousBoundsOrigin = self.scrollNode.bounds.origin
self.scrollNode.view.ignoreUpdateBounds = true
transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
self.scrollNode.view.contentSize = scrollContentSize
self.scrollNode.view.contentInset = insets
self.scrollNode.view.scrollIndicatorInsets = insets
self.scrollNode.view.ignoreUpdateBounds = false
if self.rightOverlayNode.supernode == nil {
self.insertSubnode(self.rightOverlayNode, aboveSubnode: self.scrollNode)
}
if self.leftOverlayNode.supernode == nil {
self.insertSubnode(self.leftOverlayNode, aboveSubnode: self.scrollNode)
}
self.leftOverlayNode.frame = CGRect(x: 0.0, y: 0.0, width: sideInset, height: layout.size.height)
self.rightOverlayNode.frame = CGRect(x: layout.size.width - sideInset, y: 0.0, width: sideInset, height: layout.size.height)
if let focus = self.focus {
var focusItem: ASDisplayNode?
switch focus {
case let .address(field):
switch field {
case .street1:
focusItem = self.addressItems?.address1
case .street2:
focusItem = self.addressItems?.address2
case .city:
focusItem = self.addressItems?.city
case .state:
focusItem = self.addressItems?.state
case .postcode:
focusItem = self.addressItems?.postcode
}
case .name:
focusItem = self.nameItem
case .email:
focusItem = self.emailItem
case .phone:
focusItem = self.phoneItem
}
if let focusItem = focusItem {
let scrollVisibleSize = CGSize(width: layout.size.width, height: layout.size.height - insets.top - insets.bottom)
var contentOffset = CGPoint(x: 0.0, y: -insets.top + floor(focusItem.frame.midY - scrollVisibleSize.height / 2.0))
contentOffset.y = min(contentOffset.y, scrollContentSize.height + insets.bottom - layout.size.height)
contentOffset.y = max(contentOffset.y, -insets.top)
transition.updateBounds(node: self.scrollNode, bounds: CGRect(origin: CGPoint(x: 0.0, y: contentOffset.y), size: layout.size))
if let focusItem = focusItem as? BotPaymentFieldItemNode {
focusItem.activateInput()
}
self.scrollViewDidScroll(self.scrollNode.view)
}
} else if let previousLayout = previousLayout {
var previousInsets = previousLayout.0.insets(options: [.input])
previousInsets.top += max(previousLayout.1, previousLayout.0.insets(options: [.statusBar]).top)
let insetsScrollOffset = insets.top - previousInsets.top
var contentOffset = CGPoint(x: 0.0, y: previousBoundsOrigin.y + insetsScrollOffset)
contentOffset.y = min(contentOffset.y, scrollContentSize.height + insets.bottom - layout.size.height)
contentOffset.y = max(contentOffset.y, -insets.top)
//transition.updateBounds(node: self.scrollNode, bounds: CGRect(origin: CGPoint(x: 0.0, y: contentOffset.y), size: layout.size))
} else {
let contentOffset = CGPoint(x: 0.0, y: -insets.top)
transition.updateBounds(node: self.scrollNode, bounds: CGRect(origin: CGPoint(x: 0.0, y: contentOffset.y), size: layout.size))
}
}
func animateIn() {
self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
}
func animateOut(completion: (() -> Void)? = nil) {
self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.dismiss()
}
completion?()
})
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !self.scrollNode.view.ignoreUpdateBounds else {
return
}
let value = scrollView.contentOffset.y + scrollView.contentInset.top
self.navigationBar?.updateBackgroundAlpha(min(30.0, value) / 30.0, transition: .immediate)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.focus = nil
}
}
@@ -0,0 +1,153 @@
import Foundation
import UIKit
import AsyncDisplayKit
import SwiftSignalKit
import Display
import TelegramCore
import TelegramPresentationData
import ProgressNavigationButtonNode
import AccountContext
import CountrySelectionUI
enum BotCheckoutNativeCardEntryStatus {
case notReady
case ready
case verifying
}
struct BotCheckoutNativeCardEntryAdditionalFields: OptionSet {
var rawValue: Int32
init(rawValue: Int32) {
self.rawValue = rawValue
}
static let cardholderName = BotCheckoutNativeCardEntryAdditionalFields(rawValue: 1 << 0)
static let country = BotCheckoutNativeCardEntryAdditionalFields(rawValue: 1 << 1)
static let zipCode = BotCheckoutNativeCardEntryAdditionalFields(rawValue: 1 << 2)
}
final class BotCheckoutNativeCardEntryController: ViewController {
enum Provider {
case stripe(additionalFields: BotCheckoutNativeCardEntryAdditionalFields, publishableKey: String)
case smartglobal(isTesting: Bool, publicToken: String, customTokenizeUrl: String?)
}
private var controllerNode: BotCheckoutNativeCardEntryControllerNode {
return super.displayNode as! BotCheckoutNativeCardEntryControllerNode
}
private let context: AccountContext
private let provider: Provider
private let completion: (BotCheckoutPaymentMethod) -> Void
private var presentationData: PresentationData
private var didPlayPresentationAnimation = false
private var doneItem: UIBarButtonItem?
private var activityItem: UIBarButtonItem?
public init(context: AccountContext, provider: Provider, completion: @escaping (BotCheckoutPaymentMethod) -> Void) {
self.context = context
self.provider = provider
self.completion = completion
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.doneItem = UIBarButtonItem(title: self.presentationData.strings.Common_Done, style: .done, target: self, action: #selector(self.donePressed))
self.title = self.presentationData.strings.Checkout_NewCard_Title
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
self.navigationItem.rightBarButtonItem = self.doneItem
self.doneItem?.isEnabled = false
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func loadDisplayNode() {
self.displayNode = BotCheckoutNativeCardEntryControllerNode(context: self.context, navigationBar: self.navigationBar, provider: self.provider, theme: self.presentationData.theme, strings: self.presentationData.strings, present: { [weak self] c, a in
self?.present(c, in: .window(.root), with: a)
}, dismiss: { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}, openCountrySelection: { [weak self] in
if let strongSelf = self {
let controller = AuthorizationSequenceCountrySelectionController(strings: strongSelf.presentationData.strings, theme: strongSelf.presentationData.theme, displayCodes: false)
controller.completeWithCountryCode = { _, id in
if let strongSelf = self {
strongSelf.controllerNode.updateCountry(id)
}
}
strongSelf.present(controller, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}
}, updateStatus: { [weak self] status in
if let strongSelf = self {
switch status {
case .notReady:
strongSelf.doneItem?.isEnabled = false
case .ready:
strongSelf.doneItem?.isEnabled = true
case .verifying:
break
}
switch status {
case .verifying:
if strongSelf.activityItem == nil {
strongSelf.activityItem = UIBarButtonItem(customDisplayNode: ProgressNavigationButtonNode(color: strongSelf.presentationData.theme.rootController.navigationBar.controlColor))
strongSelf.navigationItem.setRightBarButton(strongSelf.activityItem, animated: false)
}
default:
if strongSelf.activityItem != nil {
strongSelf.activityItem = nil
strongSelf.navigationItem.setRightBarButton(strongSelf.doneItem, animated: false)
}
}
}
}, completion: { [weak self] method in
self?.completion(method)
})
self.displayNodeDidLoad()
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments, !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true
if case .modalSheet = presentationArguments.presentationAnimation {
self.controllerNode.animateIn()
}
self.controllerNode.activate()
}
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
@objc func cancelPressed() {
self.dismiss()
}
@objc private func donePressed() {
self.controllerNode.verify()
}
}
@@ -0,0 +1,561 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import Stripe
import CountrySelectionUI
import PresentationDataUtils
import AccountContext
private final class BotCheckoutNativeCardEntryScrollerNodeView: UIScrollView {
var ignoreUpdateBounds = false
override var bounds: CGRect {
get {
return super.bounds
} set(value) {
if !self.ignoreUpdateBounds {
super.bounds = value
}
}
}
override func scrollRectToVisible(_ rect: CGRect, animated: Bool) {
}
}
private final class BotCheckoutNativeCardEntryScrollerNode: ASDisplayNode {
override var view: BotCheckoutNativeCardEntryScrollerNodeView {
return super.view as! BotCheckoutNativeCardEntryScrollerNodeView
}
override init() {
super.init()
self.setViewBlock({
return BotCheckoutNativeCardEntryScrollerNodeView()
})
}
}
final class BotCheckoutNativeCardEntryControllerNode: ViewControllerTracingNode, ASScrollViewDelegate {
private let context: AccountContext
private weak var navigationBar: NavigationBar?
private let provider: BotCheckoutNativeCardEntryController.Provider
private let present: (ViewController, Any?) -> Void
private let dismiss: () -> Void
private let openCountrySelection: () -> Void
private let updateStatus: (BotCheckoutNativeCardEntryStatus) -> Void
private let completion: (BotCheckoutPaymentMethod) -> Void
private var theme: PresentationTheme
private var strings: PresentationStrings
private var containerLayout: (ContainerViewLayout, CGFloat)?
private let scrollNode: BotCheckoutNativeCardEntryScrollerNode
private let itemNodes: [[BotPaymentItemNode]]
private let leftOverlayNode: ASDisplayNode
private let rightOverlayNode: ASDisplayNode
private let cardItem: BotPaymentCardInputItemNode
private let cardholderItem: BotPaymentFieldItemNode?
private let countryItem: BotPaymentDisclosureItemNode?
private let zipCodeItem: BotPaymentFieldItemNode?
private let saveInfoItem: BotPaymentSwitchItemNode
private let verifyDisposable = MetaDisposable()
private var isVerifying = false
private var currentCardData: BotPaymentCardInputData?
private var currentCountryIso2: String?
private var dataTask: URLSessionDataTask?
init(context: AccountContext, navigationBar: NavigationBar?, provider: BotCheckoutNativeCardEntryController.Provider, theme: PresentationTheme, strings: PresentationStrings, present: @escaping (ViewController, Any?) -> Void, dismiss: @escaping () -> Void, openCountrySelection: @escaping () -> Void, updateStatus: @escaping (BotCheckoutNativeCardEntryStatus) -> Void, completion: @escaping (BotCheckoutPaymentMethod) -> Void) {
self.context = context
self.navigationBar = navigationBar
self.provider = provider
self.present = present
self.dismiss = dismiss
self.openCountrySelection = openCountrySelection
self.updateStatus = updateStatus
self.completion = completion
self.theme = theme
self.strings = strings
self.scrollNode = BotCheckoutNativeCardEntryScrollerNode()
self.leftOverlayNode = ASDisplayNode()
self.leftOverlayNode.isUserInteractionEnabled = false
self.rightOverlayNode = ASDisplayNode()
self.rightOverlayNode.isUserInteractionEnabled = false
var itemNodes: [[BotPaymentItemNode]] = []
var cardUpdatedImpl: ((BotPaymentCardInputData?) -> Void)?
var openCountrySelectionImpl: (() -> Void)?
self.cardItem = BotPaymentCardInputItemNode()
self.cardItem.updated = { data in
cardUpdatedImpl?(data)
}
itemNodes.append([BotPaymentHeaderItemNode(text: strings.Checkout_NewCard_PaymentCard), self.cardItem])
switch provider {
case let .stripe(additionalFields, _):
if additionalFields.contains(.cardholderName) {
var sectionItems: [BotPaymentItemNode] = []
sectionItems.append(BotPaymentHeaderItemNode(text: strings.Checkout_NewCard_CardholderNameTitle))
let cardholderItem = BotPaymentFieldItemNode(title: "", placeholder: strings.Checkout_NewCard_CardholderNamePlaceholder, contentType: .asciiName)
self.cardholderItem = cardholderItem
sectionItems.append(cardholderItem)
itemNodes.append(sectionItems)
} else {
self.cardholderItem = nil
}
if additionalFields.contains(.country) || additionalFields.contains(.zipCode) {
var sectionItems: [BotPaymentItemNode] = []
sectionItems.append(BotPaymentHeaderItemNode(text: strings.Checkout_NewCard_PostcodeTitle))
if additionalFields.contains(.country) {
let countryItem = BotPaymentDisclosureItemNode(title: "", placeholder: strings.CheckoutInfo_ShippingInfoCountryPlaceholder, text: "")
countryItem.action = {
openCountrySelectionImpl?()
}
self.countryItem = countryItem
sectionItems.append(countryItem)
} else {
self.countryItem = nil
}
if additionalFields.contains(.zipCode) {
let zipCodeItem = BotPaymentFieldItemNode(title: "", placeholder: strings.Checkout_NewCard_PostcodePlaceholder, contentType: .address)
self.zipCodeItem = zipCodeItem
sectionItems.append(zipCodeItem)
} else {
self.zipCodeItem = nil
}
itemNodes.append(sectionItems)
} else {
self.countryItem = nil
self.zipCodeItem = nil
}
case .smartglobal:
self.cardholderItem = nil
self.countryItem = nil
self.zipCodeItem = nil
}
self.saveInfoItem = BotPaymentSwitchItemNode(title: strings.Checkout_NewCard_SaveInfo, isOn: false)
itemNodes.append([self.saveInfoItem, BotPaymentTextItemNode(text: strings.Checkout_NewCard_SaveInfoHelp)])
self.itemNodes = itemNodes
for items in itemNodes {
for item in items {
self.scrollNode.addSubnode(item)
}
}
super.init()
self.backgroundColor = self.theme.list.blocksBackgroundColor
self.leftOverlayNode.backgroundColor = self.theme.list.blocksBackgroundColor
self.rightOverlayNode.backgroundColor = self.theme.list.blocksBackgroundColor
self.scrollNode.backgroundColor = nil
self.scrollNode.isOpaque = false
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
}
self.scrollNode.view.alwaysBounceVertical = true
self.scrollNode.view.showsVerticalScrollIndicator = false
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.delegate = self.wrappedScrollViewDelegate
self.addSubnode(self.scrollNode)
cardUpdatedImpl = { [weak self] data in
if let strongSelf = self {
strongSelf.currentCardData = data
strongSelf.updateDone()
}
}
openCountrySelectionImpl = { [weak self] in
if let strongSelf = self {
strongSelf.view.endEditing(true)
strongSelf.openCountrySelection()
}
}
for items in itemNodes {
for item in items {
if let item = item as? BotPaymentFieldItemNode {
item.textUpdated = { [weak self] in
self?.updateDone()
}
item.returnPressed = { [weak self, weak item] in
guard let strongSelf = self, let item = item else {
return
}
var activateNext = true
outer: for section in strongSelf.itemNodes {
for i in 0 ..< section.count {
if section[i] === item {
activateNext = true
} else if activateNext, let field = section[i] as? BotPaymentFieldItemNode {
field.activateInput()
break outer
}
}
}
}
}
}
}
self.cardItem.completed = { [weak self] in
self?.cardholderItem?.activateInput()
}
self.updateDone()
}
deinit {
self.verifyDisposable.dispose()
self.dataTask?.cancel()
}
override func didLoad() {
super.didLoad()
self.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate)
}
func updateCountry(_ iso2: String) {
if let name = AuthorizationSequenceCountrySelectionController.lookupCountryNameById(iso2, strings: self.strings) {
self.currentCountryIso2 = iso2
self.countryItem?.text = name
if let containerLayout = self.containerLayout {
self.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.updateDone()
}
}
func verify() {
guard let cardData = self.currentCardData else {
return
}
switch self.provider {
case let .stripe(_, publishableKey):
let configuration = STPPaymentConfiguration.shared().copy() as! STPPaymentConfiguration
configuration.smsAutofillDisabled = true
configuration.publishableKey = publishableKey
configuration.appleMerchantIdentifier = "merchant.ph.telegra.Telegraph"
let apiClient = STPAPIClient(configuration: configuration)
let card = STPCardParams()
card.number = cardData.number
card.cvc = cardData.code
card.expYear = cardData.year
card.expMonth = cardData.month
card.name = self.cardholderItem?.text
card.addressCountry = self.currentCountryIso2
card.addressZip = self.zipCodeItem?.text
let createToken: Signal<STPToken, Error> = Signal { subscriber in
apiClient.createToken(withCard: card, completion: { token, error in
if let error = error {
subscriber.putError(error)
} else if let token = token {
subscriber.putNext(token)
subscriber.putCompletion()
}
})
return ActionDisposable {
let _ = apiClient.publishableKey
}
}
self.isVerifying = true
self.verifyDisposable.set((createToken |> deliverOnMainQueue).start(next: { [weak self] token in
if let strongSelf = self, let card = token.card {
let last4 = card.last4()
let brand = STPAPIClient.string(with: card.brand)
strongSelf.completion(.webToken(BotCheckoutPaymentWebToken(title: "\(brand)*\(last4)", data: "{\"type\": \"card\", \"id\": \"\(token.tokenId)\"}", saveOnServer: strongSelf.saveInfoItem.isOn)))
}
}, error: { [weak self] error in
if let strongSelf = self {
strongSelf.isVerifying = false
strongSelf.updateDone()
}
}))
self.updateDone()
case let .smartglobal(isTesting, publicToken, customTokenizeUrl):
let url: String
if let customTokenizeUrl {
url = customTokenizeUrl
} else if isTesting {
url = "https://tgb-playground.smart-glocal.com/cds/v1/tokenize/card"
} else {
url = "https://tgb.smart-glocal.com/cds/v1/tokenize/card"
}
let jsonPayload: [String: Any] = [
"card": [
"number": cardData.number,
"expiration_month": String(format: "%02d", cardData.month),
"expiration_year": String(format: "%02d", cardData.year),
"security_code": "\(cardData.code)"
] as [String: Any]
]
guard let parsedUrl = URL(string: url) else {
return
}
var request = URLRequest(url: parsedUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(publicToken, forHTTPHeaderField: "X-PUBLIC-TOKEN")
guard let requestBody = try? JSONSerialization.data(withJSONObject: jsonPayload, options: []) else {
return
}
request.httpBody = requestBody
let session = URLSession.shared
let dataTask = session.dataTask(with: request, completionHandler: { [weak self] data, response, error in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
enum ReponseError: Error {
case generic
}
do {
guard let data = data else {
throw ReponseError.generic
}
let jsonRaw = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonRaw as? [String: Any] else {
throw ReponseError.generic
}
guard let resultData = json["data"] as? [String: Any] else {
throw ReponseError.generic
}
guard let resultInfo = resultData["info"] as? [String: Any] else {
throw ReponseError.generic
}
guard let token = resultData["token"] as? String else {
throw ReponseError.generic
}
guard let maskedCardNumber = resultInfo["masked_card_number"] as? String else {
throw ReponseError.generic
}
guard let cardType = resultInfo["card_type"] as? String else {
throw ReponseError.generic
}
var last4 = maskedCardNumber
if last4.count > 4 {
let lastDigits = String(maskedCardNumber[maskedCardNumber.index(maskedCardNumber.endIndex, offsetBy: -4)...])
if lastDigits.allSatisfy(\.isNumber) {
last4 = "\(cardType) *\(lastDigits)"
}
}
let responseJson: [String: Any] = [
"type": "card",
"token": "\(token)"
]
let serializedResponseJson = try JSONSerialization.data(withJSONObject: responseJson, options: [])
guard let serializedResponseString = String(data: serializedResponseJson, encoding: .utf8) else {
throw ReponseError.generic
}
strongSelf.completion(.webToken(BotCheckoutPaymentWebToken(
title: last4,
data: serializedResponseString,
saveOnServer: strongSelf.saveInfoItem.isOn
)))
} catch {
strongSelf.isVerifying = false
strongSelf.updateDone()
strongSelf.present(textAlertController(context: strongSelf.context, title: nil, text: strongSelf.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.strings.Common_OK, action: {
})]), nil)
}
}
})
self.dataTask = dataTask
self.isVerifying = true
self.updateDone()
dataTask.resume()
break
}
}
private func updateDone() {
var enabled = true
if self.currentCardData == nil {
enabled = false
}
if let cardholderItem = self.cardholderItem, cardholderItem.text.isEmpty {
enabled = false
}
if let _ = self.countryItem, self.currentCountryIso2 == nil {
enabled = false
}
if let zipCodeItem = self.zipCodeItem, zipCodeItem.text.isEmpty {
enabled = false
}
if self.isVerifying {
self.updateStatus(.verifying)
} else if enabled {
self.updateStatus(.ready)
} else {
self.updateStatus(.notReady)
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let previousLayout = self.containerLayout
self.containerLayout = (layout, navigationBarHeight)
var insets = layout.insets(options: [.input])
insets.top += max(navigationBarHeight, layout.insets(options: [.statusBar]).top)
var contentHeight: CGFloat = 0.0
var commonInset: CGFloat = 0.0
for items in self.itemNodes {
for item in items {
commonInset = max(commonInset, item.measureInset(theme: self.theme, width: layout.size.width))
}
}
let inset = max(16.0, floor((layout.size.width - 674.0) / 2.0))
var sideInset: CGFloat = 0.0
if layout.size.width >= 375.0 {
sideInset = inset
}
for items in self.itemNodes {
if !items.isEmpty && items[0] is BotPaymentHeaderItemNode {
contentHeight += 24.0
} else {
contentHeight += 32.0
}
for i in 0 ..< items.count {
let item = items[i]
let itemHeight = item.updateLayout(theme: self.theme, width: layout.size.width, sideInset: sideInset, measuredInset: commonInset, previousItemNode: i == 0 ? nil : items[i - 1], nextItemNode: i == (items.count - 1) ? nil : items[i + 1], transition: transition)
transition.updateFrame(node: item, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: layout.size.width, height: itemHeight)))
contentHeight += itemHeight
}
}
contentHeight += 24.0
let scrollContentSize = CGSize(width: layout.size.width, height: contentHeight)
let previousBoundsOrigin = self.scrollNode.bounds.origin
self.scrollNode.view.ignoreUpdateBounds = true
if self.scrollNode.frame != CGRect(origin: CGPoint(), size: layout.size) {
transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
}
if self.scrollNode.view.contentSize != scrollContentSize {
self.scrollNode.view.contentSize = scrollContentSize
}
if self.scrollNode.view.contentInset != insets {
self.scrollNode.view.contentInset = insets
}
if self.scrollNode.view.verticalScrollIndicatorInsets != insets {
self.scrollNode.view.verticalScrollIndicatorInsets = insets
}
self.scrollNode.view.ignoreUpdateBounds = false
if self.rightOverlayNode.supernode == nil {
self.insertSubnode(self.rightOverlayNode, aboveSubnode: self.scrollNode)
}
if self.leftOverlayNode.supernode == nil {
self.insertSubnode(self.leftOverlayNode, aboveSubnode: self.scrollNode)
}
self.leftOverlayNode.frame = CGRect(x: 0.0, y: 0.0, width: sideInset, height: layout.size.height)
self.rightOverlayNode.frame = CGRect(x: layout.size.width - sideInset, y: 0.0, width: sideInset, height: layout.size.height)
if let previousLayout = previousLayout {
var previousInsets = previousLayout.0.insets(options: [.input])
previousInsets.top += max(previousLayout.1, previousLayout.0.insets(options: [.statusBar]).top)
let insetsScrollOffset = insets.top - previousInsets.top
var contentOffset = CGPoint(x: 0.0, y: previousBoundsOrigin.y + insetsScrollOffset)
contentOffset.y = min(contentOffset.y, scrollContentSize.height + insets.bottom - layout.size.height)
//contentOffset.y = max(contentOffset.y, -insets.top)
//transition.updateBounds(node: self.scrollNode, bounds: CGRect(origin: CGPoint(x: 0.0, y: contentOffset.y), size: layout.size))
} else {
let contentOffset = CGPoint(x: 0.0, y: -insets.top)
transition.updateBounds(node: self.scrollNode, bounds: CGRect(origin: CGPoint(x: 0.0, y: contentOffset.y), size: layout.size))
}
}
func animateIn() {
self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
}
func animateOut(completion: (() -> Void)? = nil) {
self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.dismiss()
}
completion?()
})
}
func activate() {
self.cardItem.activateInput()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !self.scrollNode.view.ignoreUpdateBounds else {
return
}
let value = scrollView.contentOffset.y + scrollView.contentInset.top
self.navigationBar?.updateBackgroundAlpha(min(30.0, value) / 30.0, transition: .immediate)
}
}
@@ -0,0 +1,374 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
private final class BotCheckoutPassworInputFieldNode: ASDisplayNode, UITextFieldDelegate {
private var theme: PresentationTheme
private let backgroundNode: ASImageNode
private let textInputNode: TextFieldNode
private let placeholderNode: ASTextNode
var updateHeight: (() -> Void)?
var complete: (() -> Void)?
var textChanged: ((String) -> Void)?
private let backgroundInsets = UIEdgeInsets(top: 8.0, left: 16.0, bottom: 15.0, right: 16.0)
private let inputInsets = UIEdgeInsets(top: 5.0, left: 12.0, bottom: 5.0, right: 12.0)
var text: String {
get {
return self.textInputNode.textField.text ?? ""
}
set {
self.textInputNode.textField.text = newValue
self.placeholderNode.isHidden = !newValue.isEmpty
}
}
var placeholder: String = "" {
didSet {
self.placeholderNode.attributedText = NSAttributedString(string: self.placeholder, font: Font.regular(17.0), textColor: self.theme.actionSheet.inputPlaceholderColor)
}
}
init(theme: PresentationTheme, placeholder: String) {
self.theme = theme
self.backgroundNode = ASImageNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.displaysAsynchronously = false
self.backgroundNode.displayWithoutProcessing = true
self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 12.0, color: theme.actionSheet.inputHollowBackgroundColor, strokeColor: theme.actionSheet.inputBorderColor, strokeWidth: 1.0)
self.textInputNode = TextFieldNode()
self.textInputNode.textField.typingAttributes = [NSAttributedString.Key.font: Font.regular(17.0), NSAttributedString.Key.foregroundColor: theme.actionSheet.inputTextColor]
self.textInputNode.textField.clipsToBounds = true
self.textInputNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0)
self.textInputNode.textField.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance
self.textInputNode.textField.returnKeyType = .done
self.textInputNode.textField.isSecureTextEntry = true
self.textInputNode.textField.tintColor = theme.actionSheet.controlAccentColor
self.textInputNode.textField.textColor = theme.actionSheet.inputTextColor
self.placeholderNode = ASTextNode()
self.placeholderNode.isUserInteractionEnabled = false
self.placeholderNode.displaysAsynchronously = false
self.placeholderNode.attributedText = NSAttributedString(string: placeholder, font: Font.regular(17.0), textColor: self.theme.actionSheet.inputPlaceholderColor)
super.init()
self.textInputNode.textField.delegate = self
self.textInputNode.textField.addTarget(self, action: #selector(self.textDidChange), for: .editingChanged)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.textInputNode)
self.addSubnode(self.placeholderNode)
}
func updateTheme(_ theme: PresentationTheme) {
self.theme = theme
self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 12.0, color: self.theme.actionSheet.inputHollowBackgroundColor, strokeColor: self.theme.actionSheet.inputBorderColor, strokeWidth: 1.0)
self.textInputNode.textField.keyboardAppearance = self.theme.rootController.keyboardColor.keyboardAppearance
self.placeholderNode.attributedText = NSAttributedString(string: self.placeholderNode.attributedText?.string ?? "", font: Font.regular(17.0), textColor: self.theme.actionSheet.inputPlaceholderColor)
self.textInputNode.textField.tintColor = self.theme.actionSheet.controlAccentColor
self.textInputNode.textField.typingAttributes = [NSAttributedString.Key.font: Font.regular(17.0), NSAttributedString.Key.foregroundColor: theme.actionSheet.inputTextColor]
self.textInputNode.textField.textColor = self.theme.actionSheet.inputTextColor
}
func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
let backgroundInsets = self.backgroundInsets
let inputInsets = self.inputInsets
let textFieldHeight = self.calculateTextFieldMetrics(width: width)
let panelHeight = textFieldHeight + backgroundInsets.top + backgroundInsets.bottom
let backgroundFrame = CGRect(origin: CGPoint(x: backgroundInsets.left, y: backgroundInsets.top), size: CGSize(width: width - backgroundInsets.left - backgroundInsets.right, height: panelHeight - backgroundInsets.top - backgroundInsets.bottom))
transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
let placeholderSize = self.placeholderNode.measure(backgroundFrame.size)
transition.updateFrame(node: self.placeholderNode, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + inputInsets.left, y: backgroundFrame.minY + floor((backgroundFrame.size.height - placeholderSize.height) / 2.0)), size: placeholderSize))
transition.updateFrame(node: self.textInputNode, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + inputInsets.left, y: backgroundFrame.minY), size: CGSize(width: backgroundFrame.size.width - inputInsets.left - inputInsets.right, height: backgroundFrame.size.height)))
return panelHeight
}
func activateInput() {
self.textInputNode.becomeFirstResponder()
}
func deactivateInput() {
self.textInputNode.resignFirstResponder()
}
func shake() {
self.layer.addShakeAnimation()
}
@objc func textDidChange() {
self.updateTextNodeText(animated: true)
self.textChanged?(self.textInputNode.textField.text ?? "")
self.placeholderNode.isHidden = !(self.textInputNode.textField.text ?? "").isEmpty
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if text == "\n" {
self.complete?()
return false
}
return true
}
private func calculateTextFieldMetrics(width: CGFloat) -> CGFloat {
let backgroundInsets = self.backgroundInsets
let inputInsets = self.inputInsets
let unboundTextFieldHeight = max(33.0, ceil(self.textInputNode.measure(CGSize(width: width - backgroundInsets.left - backgroundInsets.right - inputInsets.left - inputInsets.right, height: CGFloat.greatestFiniteMagnitude)).height))
return min(61.0, max(33.0, unboundTextFieldHeight))
}
private func updateTextNodeText(animated: Bool) {
let backgroundInsets = self.backgroundInsets
let textFieldHeight = self.calculateTextFieldMetrics(width: self.bounds.size.width)
let panelHeight = textFieldHeight + backgroundInsets.top + backgroundInsets.bottom
if !self.bounds.size.height.isEqual(to: panelHeight) {
self.updateHeight?()
}
}
@objc func clearPressed() {
self.textInputNode.textField.text = nil
self.deactivateInput()
}
}
private final class BotCheckoutPasswordAlertContentNode: AlertContentNode {
private let context: AccountContext
private let period: Int32
private let requiresBiometrics: Bool
private let completion: (TemporaryTwoStepPasswordToken) -> Void
private let titleNode: ASTextNode
private let textNode: ASTextNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private let cancelActionNode: TextAlertContentActionNode
private let doneActionNode: TextAlertContentActionNode
let inputFieldNode: BotCheckoutPassworInputFieldNode
private var validLayout: CGSize?
private var isVerifying = false
private let disposable = MetaDisposable()
private let hapticFeedback = HapticFeedback()
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, passwordTip: String?, cardTitle: String, period: Int32, requiresBiometrics: Bool, cancel: @escaping () -> Void, completion: @escaping (TemporaryTwoStepPasswordToken) -> Void) {
self.context = context
self.period = period
self.requiresBiometrics = requiresBiometrics
self.completion = completion
let alertTheme = AlertControllerTheme(presentationTheme: theme, fontSize: .regular)
let titleNode = ASTextNode()
titleNode.attributedText = NSAttributedString(string: strings.Checkout_PasswordEntry_Title, font: Font.semibold(17.0), textColor: theme.actionSheet.primaryTextColor, paragraphAlignment: .center)
titleNode.displaysAsynchronously = false
titleNode.isUserInteractionEnabled = false
titleNode.maximumNumberOfLines = 1
titleNode.truncationMode = .byTruncatingTail
self.titleNode = titleNode
self.textNode = ASTextNode()
self.textNode.attributedText = NSAttributedString(string: strings.Checkout_PasswordEntry_Text(cardTitle).string, font: Font.regular(13.0), textColor: theme.actionSheet.primaryTextColor, paragraphAlignment: .center)
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
self.inputFieldNode = BotCheckoutPassworInputFieldNode(theme: theme, placeholder: passwordTip ?? "")
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodesSeparator.backgroundColor = theme.actionSheet.opaqueItemSeparatorColor
self.cancelActionNode = TextAlertContentActionNode(theme: alertTheme, action: TextAlertAction(type: .genericAction, title: strings.Common_Cancel, action: {
cancel()
}))
var doneImpl: (() -> Void)?
self.doneActionNode = TextAlertContentActionNode(theme: alertTheme, action: TextAlertAction(type: .defaultAction, title: strings.Checkout_PasswordEntry_Pay, action: {
doneImpl?()
}))
self.actionNodes = [self.cancelActionNode, self.doneActionNode]
var actionVerticalSeparators: [ASDisplayNode] = []
if self.actionNodes.count > 1 {
for _ in 0 ..< self.actionNodes.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
separatorNode.backgroundColor = theme.actionSheet.opaqueItemSeparatorColor
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.addSubnode(self.inputFieldNode)
self.inputFieldNode.textChanged = { [weak self] _ in
if let strongSelf = self {
strongSelf.updateState()
}
}
self.updateState()
doneImpl = { [weak self] in
self?.verify()
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let previousLayout = self.validLayout
self.validLayout = size
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0)
let titleSize = titleNode.measure(CGSize(width: size.width - insets.left - insets.right, height: CGFloat.greatestFiniteMagnitude))
let textSize = self.textNode.measure(CGSize(width: size.width - insets.left - insets.right, height: CGFloat.greatestFiniteMagnitude))
let actionsHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionsHeight))
minActionsWidth += actionTitleSize.width + actionTitleInsets
}
let contentWidth = max(max(titleSize.width, textSize.width), minActionsWidth)
let spacing: CGFloat = 6.0
let titleFrame = CGRect(origin: CGPoint(x: insets.left + floor((contentWidth - titleSize.width) / 2.0), y: insets.top), size: titleSize)
transition.updateFrame(node: titleNode, frame: titleFrame)
let textFrame = CGRect(origin: CGPoint(x: insets.left + floor((contentWidth - textSize.width) / 2.0), y: titleFrame.maxY + spacing), size: textSize)
transition.updateFrame(node: self.textNode, frame: textFrame)
let resultSize = CGSize(width: contentWidth + insets.left + insets.right, height: titleSize.height + spacing + textSize.height + actionsHeight + insets.top + insets.bottom + 46.0)
let inputFieldWidth = resultSize.width
let inputFieldHeight = self.inputFieldNode.updateLayout(width: inputFieldWidth, transition: transition)
transition.updateFrame(node: self.inputFieldNode, frame: CGRect(x: 0.0, y: resultSize.height - 36.0 - actionsHeight - insets.bottom, width: resultSize.width, height: inputFieldHeight))
self.actionNodesSeparator.frame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
}
separatorIndex += 1
let currentActionWidth: CGFloat
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
let actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionsHeight))
actionOffset += currentActionWidth
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
if previousLayout == nil {
self.inputFieldNode.activateInput()
}
return resultSize
}
@objc func textFieldChanged(_ textField: UITextField) {
self.updateState()
}
private func updateState() {
var enabled = true
if self.isVerifying || self.inputFieldNode.text.isEmpty {
enabled = false
}
self.doneActionNode.actionEnabled = enabled
}
private func verify() {
let text = self.inputFieldNode.text
guard !text.isEmpty else {
return
}
self.isVerifying = true
self.disposable.set((self.context.engine.auth.requestTemporaryTwoStepPasswordToken(password: text, period: self.period, requiresBiometrics: self.requiresBiometrics) |> deliverOnMainQueue).start(next: { [weak self] token in
if let strongSelf = self {
strongSelf.completion(token)
}
}, error: { [weak self] _ in
if let strongSelf = self {
strongSelf.inputFieldNode.shake()
strongSelf.hapticFeedback.error()
strongSelf.isVerifying = false
strongSelf.updateState()
}
}))
self.updateState()
}
}
func botCheckoutPasswordEntryController(context: AccountContext, strings: PresentationStrings, passwordTip: String?, cartTitle: String, period: Int32, requiresBiometrics: Bool, completion: @escaping (TemporaryTwoStepPasswordToken) -> Void) -> AlertController {
var dismissImpl: (() -> Void)?
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: BotCheckoutPasswordAlertContentNode(context: context, theme: presentationData.theme, strings: strings, passwordTip: passwordTip, cardTitle: cartTitle, period: period, requiresBiometrics: requiresBiometrics, cancel: {
dismissImpl?()
}, completion: { token in
completion(token)
dismissImpl?()
}))
dismissImpl = { [weak controller] in
controller?.dismissAnimated()
}
return controller
}
@@ -0,0 +1,262 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import AccountContext
import AppBundle
struct BotCheckoutPaymentWebToken: Equatable {
let title: String
let data: String
var saveOnServer: Bool
}
enum BotCheckoutPaymentMethod: Equatable {
case savedCredentials(BotPaymentSavedCredentials)
case webToken(BotCheckoutPaymentWebToken)
case applePay
case other(BotPaymentMethod)
var title: String {
switch self {
case let .savedCredentials(credentials):
switch credentials {
case let .card(_, title):
return title
}
case let .webToken(token):
return token.title
case .applePay:
return "Apple Pay"
case let .other(method):
return method.title
}
}
}
final class BotCheckoutPaymentMethodSheetController: ActionSheetController {
private var presentationDisposable: Disposable?
init(context: AccountContext, currentMethod: BotCheckoutPaymentMethod?, methods: [BotCheckoutPaymentMethod], applyValue: @escaping (BotCheckoutPaymentMethod) -> Void, newCard: @escaping () -> Void, otherMethod: @escaping (String, String) -> Void) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let strings = presentationData.strings
super.init(theme: ActionSheetControllerTheme(presentationData: presentationData))
self.presentationDisposable = context.sharedContext.presentationData.start(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.theme = ActionSheetControllerTheme(presentationData: presentationData)
}
}).strict()
var items: [ActionSheetItem] = []
items.append(ActionSheetTextItem(title: strings.Checkout_PaymentMethod))
for method in methods {
let title: String
let icon: UIImage?
switch method {
case let .savedCredentials(credentials):
switch credentials {
case let .card(_, cardTitle):
title = cardTitle
icon = nil
}
case let .webToken(token):
title = token.title
icon = nil
case .applePay:
title = "Apple Pay"
icon = UIImage(bundleImageName: "Bot Payments/ApplePayLogo")?.precomposed()
case let .other(method):
title = method.title
icon = nil
}
let value: Bool?
if let currentMethod = currentMethod {
value = method == currentMethod
} else {
value = nil
}
items.append(BotCheckoutPaymentMethodItem(title: title, icon: icon, value: value, action: { [weak self] _ in
if case let .other(method) = method {
otherMethod(method.url, method.title)
} else {
applyValue(method)
}
self?.dismissAnimated()
}))
}
items.append(ActionSheetButtonItem(title: strings.Checkout_PaymentMethod_New, action: { [weak self] in
self?.dismissAnimated()
newCard()
}))
self.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strings.Common_Cancel, action: { [weak self] in
self?.dismissAnimated()
}),
])
])
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDisposable?.dispose()
}
}
public class BotCheckoutPaymentMethodItem: ActionSheetItem {
public let title: String
public let icon: UIImage?
public let value: Bool?
public let action: (Bool) -> Void
public init(title: String, icon: UIImage?, value: Bool?, action: @escaping (Bool) -> Void) {
self.title = title
self.icon = icon
self.value = value
self.action = action
}
public func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode {
let node = BotCheckoutPaymentMethodItemNode(theme: theme)
node.setItem(self)
return node
}
public func updateNode(_ node: ActionSheetItemNode) {
guard let node = node as? BotCheckoutPaymentMethodItemNode else {
assertionFailure()
return
}
node.setItem(self)
node.requestLayoutUpdate()
}
}
public class BotCheckoutPaymentMethodItemNode: ActionSheetItemNode {
private let defaultFont: UIFont
private let theme: ActionSheetControllerTheme
private var item: BotCheckoutPaymentMethodItem?
private let button: HighlightTrackingButton
private let titleNode: ASTextNode
private let iconNode: ASImageNode
private let checkNode: ASImageNode
public override init(theme: ActionSheetControllerTheme) {
self.theme = theme
self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0))
self.button = HighlightTrackingButton()
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
self.titleNode.isUserInteractionEnabled = false
self.titleNode.displaysAsynchronously = false
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.checkNode = ASImageNode()
self.checkNode.isUserInteractionEnabled = false
self.checkNode.displayWithoutProcessing = true
self.checkNode.displaysAsynchronously = false
self.checkNode.image = generateImage(CGSize(width: 14.0, height: 11.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(theme.controlAccentColor.cgColor)
context.setLineWidth(2.0)
context.move(to: CGPoint(x: 12.0, y: 1.0))
context.addLine(to: CGPoint(x: 4.16482734, y: 9.0))
context.addLine(to: CGPoint(x: 1.0, y: 5.81145833))
context.strokePath()
})
super.init(theme: theme)
self.view.addSubview(self.button)
self.addSubnode(self.titleNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.checkNode)
self.button.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.backgroundNode.backgroundColor = theme.itemHighlightedBackgroundColor
} else {
UIView.animate(withDuration: 0.3, animations: {
strongSelf.backgroundNode.backgroundColor = theme.itemBackgroundColor
})
}
}
}
self.button.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
}
func setItem(_ item: BotCheckoutPaymentMethodItem) {
self.item = item
self.titleNode.attributedText = NSAttributedString(string: item.title, font: self.defaultFont, textColor: self.theme.primaryTextColor)
self.iconNode.image = item.icon
if let value = item.value {
self.checkNode.isHidden = !value
} else {
self.checkNode.isHidden = true
}
}
public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let size = CGSize(width: constrainedSize.width, height: 57.0)
self.button.frame = CGRect(origin: CGPoint(), size: size)
var checkInset: CGFloat = 15.0
if let _ = self.item?.value {
checkInset = 44.0
}
let iconSize: CGSize
if let image = self.iconNode.image {
iconSize = image.size
} else {
iconSize = CGSize()
}
let titleSize = self.titleNode.measure(CGSize(width: size.width - 44.0 - iconSize.width - 15.0 - 8.0, height: size.height))
self.titleNode.frame = CGRect(origin: CGPoint(x: checkInset, y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize)
self.iconNode.frame = CGRect(origin: CGPoint(x: size.width - 15.0 - iconSize.width, y: floorToScreenPixels((size.height - iconSize.height) / 2.0)), size: iconSize)
if let image = self.checkNode.image {
self.checkNode.frame = CGRect(origin: CGPoint(x: floor((44.0 - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size)
}
self.updateInternalLayout(size, constrainedSize: constrainedSize)
return size
}
@objc func buttonPressed() {
if let item = self.item {
let updatedValue: Bool
if let value = item.value {
updatedValue = !value
} else {
updatedValue = true
}
item.action(updatedValue)
}
}
}
@@ -0,0 +1,224 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramStringFormatting
final class BotCheckoutPaymentShippingOptionSheetController: ActionSheetController {
private var presentationDisposable: Disposable?
init(context: AccountContext, currency: String, options: [BotPaymentShippingOption], currentId: String?, applyValue: @escaping (String) -> Void) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let strings = presentationData.strings
super.init(theme: ActionSheetControllerTheme(presentationData: presentationData))
self.presentationDisposable = context.sharedContext.presentationData.start(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.theme = ActionSheetControllerTheme(presentationData: presentationData)
}
}).strict()
var items: [ActionSheetItem] = []
items.append(ActionSheetTextItem(title: strings.Checkout_ShippingMethod))
let dismissAction: () -> Void = { [weak self] in
self?.dismissAnimated()
}
let toggleCheck: (String, Int) -> Void = { [weak self] id, itemIndex in
for i in 0 ..< options.count {
self?.updateItem(groupIndex: 0, itemIndex: i + 1, { item in
if let item = item as? BotCheckoutPaymentShippingOptionItem, let value = item.value {
return BotCheckoutPaymentShippingOptionItem(title: item.title, label: item.label, value: i == itemIndex ? !value : false, action: item.action)
}
return item
})
}
applyValue(id)
dismissAction()
}
var itemIndex = 0
for option in options {
let index = itemIndex
var totalPrice: Int64 = 0
for price in option.prices {
totalPrice += price.amount
}
let value: Bool?
if let currentId = currentId {
value = option.id == currentId
} else {
value = nil
}
items.append(BotCheckoutPaymentShippingOptionItem(title: option.title, label: formatCurrencyAmount(totalPrice, currency: currency), value: value, action: { value in
toggleCheck(option.id, index)
}))
itemIndex += 1
}
self.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strings.Common_Cancel, action: { [weak self] in
self?.dismissAnimated()
}),
])
])
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDisposable?.dispose()
}
}
public class BotCheckoutPaymentShippingOptionItem: ActionSheetItem {
public let title: String
public let label: String
public let value: Bool?
public let action: (Bool) -> Void
public init(title: String, label: String, value: Bool?, action: @escaping (Bool) -> Void) {
self.title = title
self.label = label
self.value = value
self.action = action
}
public func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode {
let node = BotCheckoutPaymentShippingOptionItemNode(theme: theme)
node.setItem(self)
return node
}
public func updateNode(_ node: ActionSheetItemNode) {
guard let node = node as? BotCheckoutPaymentShippingOptionItemNode else {
assertionFailure()
return
}
node.setItem(self)
node.requestLayoutUpdate()
}
}
public class BotCheckoutPaymentShippingOptionItemNode: ActionSheetItemNode {
private let defaultFont: UIFont
private let theme: ActionSheetControllerTheme
private var item: BotCheckoutPaymentShippingOptionItem?
private let button: HighlightTrackingButton
private let titleNode: ASTextNode
private let labelNode: ASTextNode
private let checkNode: ASImageNode
public override init(theme: ActionSheetControllerTheme) {
self.theme = theme
self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0))
self.button = HighlightTrackingButton()
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
self.titleNode.isUserInteractionEnabled = false
self.titleNode.displaysAsynchronously = false
self.labelNode = ASTextNode()
self.labelNode.maximumNumberOfLines = 1
self.labelNode.isUserInteractionEnabled = false
self.labelNode.displaysAsynchronously = false
self.checkNode = ASImageNode()
self.checkNode.isUserInteractionEnabled = false
self.checkNode.displayWithoutProcessing = true
self.checkNode.displaysAsynchronously = false
self.checkNode.image = generateImage(CGSize(width: 14.0, height: 11.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(theme.controlAccentColor.cgColor)
context.setLineWidth(2.0)
context.move(to: CGPoint(x: 12.0, y: 1.0))
context.addLine(to: CGPoint(x: 4.16482734, y: 9.0))
context.addLine(to: CGPoint(x: 1.0, y: 5.81145833))
context.strokePath()
})
super.init(theme: theme)
self.view.addSubview(self.button)
self.addSubnode(self.titleNode)
self.addSubnode(self.labelNode)
self.addSubnode(self.checkNode)
self.button.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemHighlightedBackgroundColor
} else {
UIView.animate(withDuration: 0.3, animations: {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemBackgroundColor
})
}
}
}
self.button.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
}
func setItem(_ item: BotCheckoutPaymentShippingOptionItem) {
self.item = item
self.titleNode.attributedText = NSAttributedString(string: item.title, font: self.defaultFont, textColor: self.theme.primaryTextColor)
self.labelNode.attributedText = NSAttributedString(string: item.label, font: self.defaultFont, textColor: self.theme.primaryTextColor)
if let value = item.value {
self.checkNode.isHidden = !value
} else {
self.checkNode.isHidden = true
}
}
public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let size = CGSize(width: constrainedSize.width, height: 57.0)
self.button.frame = CGRect(origin: CGPoint(), size: size)
var checkInset: CGFloat = 15.0
if let _ = self.item?.value {
checkInset = 44.0
}
let labelSize = self.labelNode.measure(CGSize(width: size.width - 44.0 - 15.0 - 8.0, height: size.height))
let titleSize = self.titleNode.measure(CGSize(width: size.width - 44.0 - labelSize.width - 15.0 - 8.0, height: size.height))
self.titleNode.frame = CGRect(origin: CGPoint(x: checkInset, y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize)
self.labelNode.frame = CGRect(origin: CGPoint(x: size.width - 15.0 - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize)
if let image = self.checkNode.image {
self.checkNode.frame = CGRect(origin: CGPoint(x: floor((44.0 - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size)
}
self.updateInternalLayout(size, constrainedSize: constrainedSize)
return size
}
@objc func buttonPressed() {
if let item = self.item {
let updatedValue: Bool
if let value = item.value {
updatedValue = !value
} else {
updatedValue = true
}
item.action(updatedValue)
}
}
}
@@ -0,0 +1,270 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import ShimmerEffect
class BotCheckoutPriceItem: ListViewItem, ItemListItem {
let theme: PresentationTheme
let title: String
let label: String
let isFinal: Bool
let hasSeparator: Bool
let shimmeringIndex: Int?
let sectionId: ItemListSectionId
let requestsNoInset: Bool = true
init(theme: PresentationTheme, title: String, label: String, isFinal: Bool, hasSeparator: Bool, shimmeringIndex: Int?, sectionId: ItemListSectionId) {
self.theme = theme
self.title = title
self.label = label
self.isFinal = isFinal
self.hasSeparator = hasSeparator
self.shimmeringIndex = shimmeringIndex
self.sectionId = sectionId
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = BotCheckoutPriceItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem), previousItem, nextItem)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? BotCheckoutPriceItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem), previousItem, nextItem)
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
let selectable: Bool = false
}
private let titleFont = Font.regular(17.0)
private let finalFont = Font.semibold(17.0)
private func priceItemInsets(_ neighbors: ItemListNeighbors) -> UIEdgeInsets {
var insets = UIEdgeInsets()
switch neighbors.top {
case .otherSection:
insets.top += 24.0
case .none, .sameSection:
break
}
switch neighbors.bottom {
case .none, .otherSection:
insets.bottom += 24.0
case .sameSection:
break
}
return insets
}
class BotCheckoutPriceItemNode: ListViewItemNode {
let titleNode: TextNode
let labelNode: TextNode
let backgroundNode: ASDisplayNode
let separatorNode: ASDisplayNode
let bottomSeparatorNode: ASDisplayNode
private let maskNode: ASImageNode
private var placeholderNode: ShimmerEffectNode?
private var absoluteLocation: (CGRect, CGSize)?
private var item: BotCheckoutPriceItem?
init() {
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.labelNode = TextNode()
self.labelNode.isUserInteractionEnabled = false
self.backgroundNode = ASDisplayNode()
self.separatorNode = ASDisplayNode()
self.bottomSeparatorNode = ASDisplayNode()
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.labelNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.bottomSeparatorNode)
}
override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
var rect = rect
rect.origin.y += self.insets.top
self.absoluteLocation = (rect, containerSize)
if let shimmerNode = self.placeholderNode {
shimmerNode.updateAbsoluteRect(rect, within: containerSize)
}
}
func asyncLayout() -> (_ item: BotCheckoutPriceItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors, _ previousItem: ListViewItem?, _ nextItem: ListViewItem?) -> (ListViewItemNodeLayout, () -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeLabelLayout = TextNode.asyncLayout(self.labelNode)
return { item, params, neighbors, previousItem, nextItem in
let rightInset: CGFloat = 16.0 + params.rightInset
let naturalContentHeight: CGFloat
var verticalOffset: CGFloat = 0.0
if item.isFinal {
naturalContentHeight = 52.0
} else {
switch neighbors.bottom {
case .otherSection, .none:
naturalContentHeight = 52.0
default:
naturalContentHeight = 42.0
}
}
if let _ = previousItem as? BotCheckoutHeaderItem {
verticalOffset += 8.0
}
var contentSize = CGSize(width: params.width, height: naturalContentHeight + verticalOffset)
if let nextItem = nextItem as? BotCheckoutPriceItem {
if nextItem.isFinal {
contentSize.height += 8.0
}
}
let insets = priceItemInsets(neighbors)
let textFont: UIFont
let textColor: UIColor
if item.isFinal {
textFont = finalFont
textColor = item.theme.list.itemPrimaryTextColor
} else {
textFont = titleFont
textColor = item.theme.list.itemSecondaryTextColor
}
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: textFont, textColor: textColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - params.leftInset - params.rightInset - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (labelLayout, labelApply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.label, font: textFont, textColor: textColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - params.leftInset - params.rightInset - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] in
if let strongSelf = self {
strongSelf.item = item
let _ = titleApply()
let _ = labelApply()
let leftInset: CGFloat = 16.0 + params.leftInset
if strongSelf.maskNode.supernode == nil {
strongSelf.addSubnode(strongSelf.maskNode)
}
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
break
default:
hasTopCorners = true
}
switch neighbors.bottom {
case .sameSection(false):
strongSelf.bottomSeparatorNode.isHidden = false
default:
hasBottomCorners = true
strongSelf.bottomSeparatorNode.isHidden = !item.isFinal || hasCorners
}
strongSelf.separatorNode.isHidden = !item.hasSeparator || (hasCorners && hasTopCorners)
strongSelf.separatorNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: params.width - leftInset, height: UIScreenPixel))
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
strongSelf.bottomSeparatorNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.bottomSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: CGSize(width: params.width, height: UIScreenPixel))
strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: params.width, height: contentSize.height))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: verticalOffset + floor((naturalContentHeight - titleLayout.size.height) / 2.0)), size: titleLayout.size)
strongSelf.labelNode.frame = CGRect(origin: CGPoint(x: params.width - rightInset - labelLayout.size.width, y: verticalOffset + floor((naturalContentHeight - labelLayout.size.height) / 2.0)), size: labelLayout.size)
if let shimmeringIndex = item.shimmeringIndex {
let shimmerNode: ShimmerEffectNode
if let current = strongSelf.placeholderNode {
shimmerNode = current
} else {
shimmerNode = ShimmerEffectNode()
strongSelf.placeholderNode = shimmerNode
if strongSelf.separatorNode.supernode != nil {
strongSelf.insertSubnode(shimmerNode, belowSubnode: strongSelf.separatorNode)
} else {
strongSelf.addSubnode(shimmerNode)
}
}
shimmerNode.frame = CGRect(origin: CGPoint(), size: contentSize)
if let (rect, size) = strongSelf.absoluteLocation {
shimmerNode.updateAbsoluteRect(rect, within: size)
}
var shapes: [ShimmerEffectNode.Shape] = []
let titleLineWidth: CGFloat = (shimmeringIndex % 2 == 0) ? 120.0 : 80.0
let lineDiameter: CGFloat = 8.0
let titleFrame = strongSelf.titleNode.frame
shapes.append(.roundedRectLine(startPoint: CGPoint(x: titleFrame.minX, y: titleFrame.minY + floor((titleFrame.height - lineDiameter) / 2.0)), width: titleLineWidth, diameter: lineDiameter))
shimmerNode.update(backgroundColor: item.theme.list.itemBlocksBackgroundColor, foregroundColor: item.theme.list.mediaPlaceholderColor, shimmeringColor: item.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: contentSize)
} else if let shimmerNode = strongSelf.placeholderNode {
strongSelf.placeholderNode = nil
shimmerNode.removeFromSupernode()
}
}
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateAdded(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
}
@@ -0,0 +1,786 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import TelegramStringFormatting
class BotCheckoutTipItem: ListViewItem, ItemListItem {
let theme: PresentationTheme
let strings: PresentationStrings
let title: String
let currency: String
let value: String
let numericValue: Int64
let maxValue: Int64
let availableVariants: [(String, Int64)]
let updateValue: (Int64) -> Void
let updatedFocus: (Bool) -> Void
let sectionId: ItemListSectionId
let requestsNoInset: Bool = true
init(theme: PresentationTheme, strings: PresentationStrings, title: String, currency: String, value: String, numericValue: Int64, maxValue: Int64, availableVariants: [(String, Int64)], sectionId: ItemListSectionId, updateValue: @escaping (Int64) -> Void, updatedFocus: @escaping (Bool) -> Void) {
self.theme = theme
self.strings = strings
self.title = title
self.currency = currency
self.value = value
self.numericValue = numericValue
self.maxValue = maxValue
self.availableVariants = availableVariants
self.updateValue = updateValue
self.updatedFocus = updatedFocus
self.sectionId = sectionId
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = BotCheckoutTipItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? BotCheckoutTipItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
let selectable: Bool = false
}
private let titleFont = Font.regular(17.0)
private let finalFont = Font.semibold(17.0)
private func priceItemInsets(_ neighbors: ItemListNeighbors) -> UIEdgeInsets {
var insets = UIEdgeInsets()
switch neighbors.top {
case .otherSection:
insets.top += 8.0
case .none, .sameSection:
break
}
switch neighbors.bottom {
case .none, .otherSection:
insets.bottom += 8.0
case .sameSection:
break
}
return insets
}
private final class TipValueNode: ASDisplayNode {
private let backgroundNode: ASImageNode
private let titleNode: ImmediateTextNode
private let button: HighlightTrackingButtonNode
private var currentBackgroundColor: UIColor?
var action: (() -> Void)?
override init() {
self.backgroundNode = ASImageNode()
self.titleNode = ImmediateTextNode()
self.button = HighlightTrackingButtonNode()
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.button)
self.button.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
}
@objc private func buttonPressed() {
self.action?()
}
func update(theme: PresentationTheme, text: String, isHighlighted: Bool, height: CGFloat) -> (CGFloat, (CGFloat) -> Void) {
var updateBackground = false
let backgroundColor = isHighlighted ? theme.list.paymentOption.activeFillColor : theme.list.paymentOption.inactiveFillColor
if let currentBackgroundColor = self.currentBackgroundColor {
if !currentBackgroundColor.isEqual(backgroundColor) {
updateBackground = true
}
} else {
updateBackground = true
}
if updateBackground {
self.currentBackgroundColor = backgroundColor
self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 20.0, color: backgroundColor)
}
self.titleNode.attributedText = NSAttributedString(string: text, font: Font.semibold(15.0), textColor: isHighlighted ? theme.list.paymentOption.activeForegroundColor : theme.list.paymentOption.inactiveForegroundColor)
let titleSize = self.titleNode.updateLayout(CGSize(width: 200.0, height: height))
let minWidth: CGFloat = 80.0
let calculatedWidth = max(titleSize.width + 16.0 * 2.0, minWidth)
return (calculatedWidth, { calculatedWidth in
self.titleNode.frame = CGRect(origin: CGPoint(x: floor((calculatedWidth - titleSize.width) / 2.0), y: floor((height - titleSize.height) / 2.0)), size: titleSize)
let size = CGSize(width: calculatedWidth, height: height)
self.backgroundNode.frame = CGRect(origin: CGPoint(), size: size)
self.button.frame = CGRect(origin: CGPoint(), size: size)
})
}
}
private final class FormatterImpl: 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 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, 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.focusUpdated = focusUpdated
self.representation = Representation(string: initialValue, format: format)
super.init()
textField.text = self.representation.string
self.previousResolvedCaretIndex = self.representation.stringCaretIndex
}
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)
}
}
class BotCheckoutTipItemNode: ListViewItemNode, UITextFieldDelegate {
private let backgroundNode: ASDisplayNode
private let maskNode: ASImageNode
let titleNode: TextNode
let labelNode: TextNode
let tipMeasurementNode: ImmediateTextNode
let tipCurrencyNode: ImmediateTextNode
private let textNode: TextFieldNode
private let scrollNode: ASScrollNode
private var valueNodes: [TipValueNode] = []
private var item: BotCheckoutTipItem?
private var formatter: FormatterImpl?
init() {
self.backgroundNode = ASDisplayNode()
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.labelNode = TextNode()
self.labelNode.isUserInteractionEnabled = false
self.labelNode.isHidden = true
self.tipMeasurementNode = ImmediateTextNode()
self.tipCurrencyNode = ImmediateTextNode()
self.textNode = TextFieldNode()
self.scrollNode = ASScrollNode()
self.scrollNode.view.disablesInteractiveTransitionGestureRecognizer = true
self.scrollNode.view.showsVerticalScrollIndicator = false
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.scrollsToTop = false
self.scrollNode.view.delaysContentTouches = false
self.scrollNode.view.canCancelContentTouches = true
if #available(iOS 11.0, *) {
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
}
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.labelNode)
self.addSubnode(self.textNode)
self.addSubnode(self.tipCurrencyNode)
self.addSubnode(self.scrollNode)
self.textNode.clipsToBounds = true
self.textNode.textField.addTarget(self, action: #selector(self.textFieldTextChanged(_:)), for: .editingChanged)
self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0)
}
func asyncLayout() -> (_ item: BotCheckoutTipItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeLabelLayout = TextNode.asyncLayout(self.labelNode)
return { item, params, neighbors in
//let rightInset: CGFloat = 16.0 + params.rightInset
let labelsContentHeight: CGFloat = 34.0
var contentSize = CGSize(width: params.width, height: labelsContentHeight)
if !item.availableVariants.isEmpty {
contentSize.height += 75.0
}
let insets = priceItemInsets(neighbors)
let textFont: UIFont
let textColor: UIColor
textFont = titleFont
textColor = item.theme.list.itemSecondaryTextColor
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: textFont, textColor: textColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - params.leftInset - params.rightInset - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (labelLayout, labelApply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.strings.Checkout_OptionalTipItemPlaceholder, font: textFont, textColor: textColor.withMultipliedAlpha(0.8)), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - params.leftInset - params.rightInset - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] in
if let strongSelf = self {
strongSelf.item = item
let _ = titleApply()
let _ = labelApply()
let leftInset: CGFloat = 16.0 + params.leftInset
strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((labelsContentHeight - titleLayout.size.height) / 2.0)), size: titleLayout.size)
strongSelf.labelNode.frame = CGRect(origin: CGPoint(x: params.width - leftInset - labelLayout.size.width, y: floor((labelsContentHeight - labelLayout.size.height) / 2.0)), size: labelLayout.size)
if strongSelf.formatter == nil {
strongSelf.formatter = FormatterImpl(textField: strongSelf.textNode.textField, currency: item.currency, maxNumericalValue: item.maxValue, initialValue: item.value, updated: { value in
guard let strongSelf = self, let item = strongSelf.item else {
return
}
if item.numericValue != value {
item.updateValue(value)
}
}, focusUpdated: { value in
guard let strongSelf = self else {
return
}
if value {
strongSelf.item?.updatedFocus(true)
}
})
strongSelf.textNode.textField.delegate = strongSelf.formatter
/*strongSelf.formatterDelegate = CurrencyUITextFieldDelegate(formatter: CurrencyFormatter(currency: item.currency, { formatter in
formatter.maxValue = currencyToFractionalAmount(value: item.maxValue, currency: item.currency) ?? 10000.0
formatter.minValue = 0.0
formatter.hasDecimals = true
}))
strongSelf.formatterDelegate?.passthroughDelegate = strongSelf
strongSelf.formatterDelegate?.textUpdated = {
guard let strongSelf = self else {
return
}
strongSelf.textFieldTextChanged(strongSelf.textNode.textField)
}
strongSelf.textNode.textField.delegate = strongSelf.formatterDelegate*/
strongSelf.textNode.clipsToBounds = true
//strongSelf.textNode.textField.delegate = strongSelf
}
strongSelf.textNode.textField.typingAttributes = [NSAttributedString.Key.font: titleFont]
strongSelf.textNode.textField.font = titleFont
strongSelf.textNode.textField.textColor = textColor
strongSelf.textNode.textField.textAlignment = .right
strongSelf.textNode.textField.keyboardAppearance = item.theme.rootController.keyboardColor.keyboardAppearance
strongSelf.textNode.textField.keyboardType = .decimalPad
strongSelf.textNode.textField.returnKeyType = .next
strongSelf.textNode.textField.tintColor = item.theme.list.itemAccentColor
var textInputFrame = CGRect(origin: CGPoint(x: params.width - leftInset - 150.0, y: -2.0), size: CGSize(width: 150.0, height: labelsContentHeight))
let currencyText: (String, String, Bool) = formatCurrencyAmountCustom(item.numericValue, currency: item.currency)
let currencySymbolOnTheLeft = currencyText.2
//let currencySymbolOnTheLeft = true
if strongSelf.textNode.textField.text ?? "" != currencyText.0 {
strongSelf.formatter?.reset(textField: strongSelf.textNode.textField, initialValue: currencyText.0)
}
strongSelf.tipMeasurementNode.attributedText = NSAttributedString(string: currencyText.0, font: titleFont, textColor: textColor)
let inputTextSize = strongSelf.tipMeasurementNode.updateLayout(textInputFrame.size)
let spaceRect = NSAttributedString(string: " ", font: titleFont, textColor: textColor).boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
strongSelf.tipCurrencyNode.attributedText = NSAttributedString(string: "\(currencyText.1)", font: titleFont, textColor: textColor)
let currencySize = strongSelf.tipCurrencyNode.updateLayout(CGSize(width: 100.0, height: .greatestFiniteMagnitude))
if currencySymbolOnTheLeft {
strongSelf.tipCurrencyNode.frame = CGRect(origin: CGPoint(x: textInputFrame.maxX - currencySize.width - inputTextSize.width - spaceRect.width, y: floor((labelsContentHeight - currencySize.height) / 2.0) - 1.0), size: currencySize)
} else {
strongSelf.tipCurrencyNode.frame = CGRect(origin: CGPoint(x: textInputFrame.maxX - currencySize.width, y: floor((labelsContentHeight - currencySize.height) / 2.0) - 1.0), size: currencySize)
textInputFrame.origin.x -= currencySize.width + spaceRect.width
}
strongSelf.textNode.frame = textInputFrame
let valueHeight: CGFloat = 52.0
let valueY: CGFloat = labelsContentHeight + 9.0
var index = 0
var variantLayouts: [(CGFloat, (CGFloat) -> Void)] = []
var totalMinWidth: CGFloat = 0.0
for (variantText, variantValue) in item.availableVariants {
let valueNode: TipValueNode
if strongSelf.valueNodes.count > index {
valueNode = strongSelf.valueNodes[index]
} else {
valueNode = TipValueNode()
strongSelf.valueNodes.append(valueNode)
strongSelf.scrollNode.addSubnode(valueNode)
}
let (nodeMinWidth, nodeApply) = valueNode.update(theme: item.theme, text: variantText, isHighlighted: item.value == variantText, height: valueHeight)
valueNode.action = {
guard let strongSelf = self, let item = strongSelf.item else {
return
}
if item.numericValue == variantValue {
item.updateValue(0)
} else {
item.updateValue(variantValue)
}
}
totalMinWidth += nodeMinWidth
variantLayouts.append((nodeMinWidth, nodeApply))
index += 1
}
let sideInset: CGFloat = params.leftInset + 16.0
var scaleFactor: CGFloat = 1.0
let availableWidth = params.width - sideInset * 2.0 - CGFloat(max(0, item.availableVariants.count - 1)) * 12.0
if totalMinWidth < availableWidth {
scaleFactor = availableWidth / totalMinWidth
}
var variantsOffset: CGFloat = sideInset
for index in 0 ..< item.availableVariants.count {
if index != 0 {
variantsOffset += 12.0
}
let valueNode: TipValueNode = strongSelf.valueNodes[index]
let (minWidth, nodeApply) = variantLayouts[index]
let nodeWidth = floor(scaleFactor * minWidth)
var valueFrame = CGRect(origin: CGPoint(x: variantsOffset, y: 0.0), size: CGSize(width: nodeWidth, height: valueHeight))
if scaleFactor > 1.0 && index == item.availableVariants.count - 1 {
valueFrame.size.width = params.width - sideInset - valueFrame.minX
}
valueNode.frame = valueFrame
nodeApply(nodeWidth)
variantsOffset += nodeWidth
}
variantsOffset += 16.0
strongSelf.scrollNode.frame = CGRect(origin: CGPoint(x: 0.0, y: valueY), size: CGSize(width: params.width, height: max(0.0, contentSize.height - valueY)))
strongSelf.scrollNode.view.contentSize = CGSize(width: variantsOffset, height: strongSelf.scrollNode.frame.height)
strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: params.width, height: contentSize.height))
}
})
}
}
@objc private func dismissKeyboard() {
self.textNode.textField.resignFirstResponder()
}
@objc private func textFieldTextChanged(_ textField: UITextField) {
let text = textField.text ?? ""
//self.labelNode.isHidden = !text.isEmpty
guard let item = self.item else {
return
}
if text.isEmpty {
item.updateValue(0)
return
}
/*var cleanText = ""
for c in text {
if c.isNumber {
cleanText.append(c)
} else if c == "," {
cleanText.append(".")
}
}
guard let doubleValue = Double(cleanText) else {
return
}
if var value = fractionalToCurrencyAmount(value: doubleValue, currency: item.currency) {
if value > item.maxValue {
value = item.maxValue
let currencyText = formatCurrencyAmountCustom(value, currency: item.currency)
if self.textNode.textField.text ?? "" != currencyText.0 {
self.textNode.textField.text = currencyText.0
}
}
item.updateValue(value)
}*/
}
@objc public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
return true
}
@objc public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
return false
}
@objc public func textFieldDidBeginEditing(_ textField: UITextField) {
textField.selectedTextRange = textField.textRange(from: textField.endOfDocument, to: textField.endOfDocument)
self.item?.updatedFocus(true)
}
@objc public func textFieldDidChangeSelection(_ textField: UITextField) {
textField.selectedTextRange = textField.textRange(from: textField.endOfDocument, to: textField.endOfDocument)
}
@objc public func textFieldDidEndEditing(_ textField: UITextField) {
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateAdded(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
}
@@ -0,0 +1,86 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
enum BotCheckoutWebInteractionControllerIntent {
case addPaymentMethod(customTitle: String?, completion: (BotCheckoutPaymentWebToken) -> Void)
case externalVerification((Bool) -> Void)
}
final class BotCheckoutWebInteractionController: ViewController {
private var controllerNode: BotCheckoutWebInteractionControllerNode {
return self.displayNode as! BotCheckoutWebInteractionControllerNode
}
private let context: AccountContext
private let url: String
private let intent: BotCheckoutWebInteractionControllerIntent
private var presentationData: PresentationData
private var didPlayPresentationAnimation = false
init(context: AccountContext, url: String, intent: BotCheckoutWebInteractionControllerIntent) {
self.context = context
self.url = url
self.intent = intent
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: (context.sharedContext.currentPresentationData.with { $0 })))
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
switch intent {
case let .addPaymentMethod(customTitle, _):
self.title = customTitle ?? self.presentationData.strings.Checkout_NewCard_Title
case .externalVerification:
self.title = self.presentationData.strings.Checkout_WebConfirmation_Title
}
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func cancelPressed() {
if case let .externalVerification(completion) = self.intent {
completion(false)
}
self.dismiss()
}
override func loadDisplayNode() {
self.displayNode = BotCheckoutWebInteractionControllerNode(context: self.context, presentationData: self.presentationData, url: self.url, intent: self.intent)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true
self.controllerNode.animateIn()
}
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
override var presentationController: UIPresentationController? {
get {
return nil
} set(value) {
}
}
}
@@ -0,0 +1,171 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
@preconcurrency import WebKit
import TelegramPresentationData
import AccountContext
private class WeakPaymentScriptMessageHandler: NSObject, WKScriptMessageHandler {
private let f: (WKScriptMessage) -> ()
init(_ f: @escaping (WKScriptMessage) -> ()) {
self.f = f
super.init()
}
func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) {
self.f(scriptMessage)
}
}
final class BotCheckoutWebInteractionControllerNode: ViewControllerTracingNode, WKNavigationDelegate {
private let context: AccountContext
private var presentationData: PresentationData
private let intent: BotCheckoutWebInteractionControllerIntent
private var webView: WKWebView?
init(context: AccountContext, presentationData: PresentationData, url: String, intent: BotCheckoutWebInteractionControllerIntent) {
self.context = context
self.presentationData = presentationData
self.intent = intent
super.init()
self.backgroundColor = .white
let webView: WKWebView
switch intent {
case .addPaymentMethod:
let js = "var TelegramWebviewProxyProto = function() {}; " +
"TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { " +
"window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); " +
"}; " +
"var TelegramWebviewProxy = new TelegramWebviewProxyProto();"
let configuration = WKWebViewConfiguration()
let userController = WKUserContentController()
let userScript = WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: false)
userController.addUserScript(userScript)
userController.add(WeakPaymentScriptMessageHandler { [weak self] message in
if let strongSelf = self {
strongSelf.handleScriptMessage(message)
}
}, name: "performAction")
configuration.userContentController = userController
webView = WKWebView(frame: CGRect(), configuration: configuration)
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
webView.allowsLinkPreview = false
}
webView.navigationDelegate = self
case .externalVerification:
webView = WKWebView()
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
webView.allowsLinkPreview = false
}
webView.navigationDelegate = self
}
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
webView.scrollView.contentInsetAdjustmentBehavior = .never
}
self.webView = webView
self.view.addSubview(webView)
if let parsedUrl = URL(string: url) {
webView.load(URLRequest(url: parsedUrl))
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.webView?.frame = CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: max(1.0, layout.size.height - navigationBarHeight)))
}
func animateIn() {
self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
}
func animateOut(completion: (() -> Void)? = nil) {
self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { _ in
completion?()
})
}
private func handleScriptMessage(_ message: WKScriptMessage) {
guard let body = message.body as? [String: Any] else {
return
}
guard let eventName = body["eventName"] as? String else {
return
}
if eventName == "payment_form_submit" {
guard let eventString = body["eventData"] as? String else {
return
}
guard let eventData = eventString.data(using: .utf8) else {
return
}
guard let dict = (try? JSONSerialization.jsonObject(with: eventData, options: [])) as? [String: Any] else {
return
}
guard let title = dict["title"] as? String else {
return
}
guard let credentials = dict["credentials"] else {
return
}
guard let credentialsData = try? JSONSerialization.data(withJSONObject: credentials, options: []) else {
return
}
guard let credentialsString = String(data: credentialsData, encoding: .utf8) else {
return
}
if case let .addPaymentMethod(_, completion) = self.intent {
completion(BotCheckoutPaymentWebToken(title: title, data: credentialsString, saveOnServer: false))
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if case let .externalVerification(completion) = self.intent, let host = navigationAction.request.url?.host {
if host == "t.me" || host == "telegram.me" {
decisionHandler(.cancel)
completion(true)
} else {
if let url = navigationAction.request.url, let scheme = url.scheme {
let defaultSchemes: [String] = ["http", "https"]
if !defaultSchemes.contains(scheme) {
decisionHandler(.cancel)
self.context.sharedContext.applicationBindings.openUrl(url.absoluteString)
return
}
}
decisionHandler(.allow)
}
} else {
if let url = navigationAction.request.url, let scheme = url.scheme {
let defaultSchemes: [String] = ["http", "https"]
if !defaultSchemes.contains(scheme) {
decisionHandler(.cancel)
self.context.sharedContext.applicationBindings.openUrl(url.absoluteString)
return
}
}
decisionHandler(.allow)
}
}
}
@@ -0,0 +1,90 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private let titleFont = Font.regular(17.0)
final class BotPaymentActionItemNode: BotPaymentItemNode {
private let title: String
private let highlightedBackgroundNode: ASDisplayNode
private let titleNode: ASTextNode
private let buttonNode: HighlightTrackingButtonNode
private var theme: PresentationTheme?
var action: (() -> Void)?
init(title: String) {
self.title = title
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.alpha = 0.0
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
self.buttonNode = HighlightTrackingButtonNode()
super.init(needsBackground: true)
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.buttonNode)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
if let supernode = strongSelf.supernode {
supernode.view.bringSubviewToFront(strongSelf.view)
}
strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.highlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
}
self.buttonNode.addTarget(self, action: #selector(buttonPressed), forControlEvents: .touchUpInside)
}
override func measureInset(theme: PresentationTheme, width: CGFloat) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.highlightedBackgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
self.titleNode.attributedText = NSAttributedString(string: self.title, font: titleFont, textColor: theme.list.itemAccentColor)
}
return 0.0
}
override func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.highlightedBackgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
self.titleNode.attributedText = NSAttributedString(string: self.title, font: titleFont, textColor: theme.list.itemAccentColor)
}
let height: CGFloat = 52.0
self.buttonNode.frame = CGRect(origin: CGPoint(x: sideInset, y: 0.0), size: CGSize(width: width - sideInset * 2.0, height: height))
transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: height + UIScreenPixel)))
let leftInset: CGFloat = 16.0
let titleSize = self.titleNode.measure(CGSize(width: width - leftInset - 32.0 - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: 16.0), size: titleSize))
return height
}
@objc func buttonPressed() {
self.action?()
}
}
@@ -0,0 +1,68 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import Stripe
struct BotPaymentCardInputData {
let number: String
let code: String
let year: UInt
let month: UInt
}
final class BotPaymentCardInputItemNode: BotPaymentItemNode, STPPaymentCardTextFieldDelegate {
private let cardField: STPPaymentCardTextField
private var theme: PresentationTheme?
var updated: ((BotPaymentCardInputData?) -> Void)?
var completed: (() -> Void)?
init() {
self.cardField = STPPaymentCardTextField()
self.cardField.borderColor = .clear
self.cardField.borderWidth = 0.0
super.init(needsBackground: true)
self.cardField.delegate = self
self.view.addSubview(self.cardField)
}
override func measureInset(theme: PresentationTheme, width: CGFloat) -> CGFloat {
return 0.0
}
override func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.cardField.textColor = theme.list.itemPrimaryTextColor
self.cardField.textErrorColor = theme.list.itemDestructiveColor
self.cardField.placeholderColor = theme.list.itemPlaceholderTextColor
self.cardField.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance
}
self.cardField.frame = CGRect(origin: CGPoint(x: 5.0 + sideInset, y: 4.0), size: CGSize(width: width - 10.0 - sideInset * 2.0, height: 44.0))
return 52.0
}
func paymentCardTextFieldDidChange(_ textField: STPPaymentCardTextField) {
if textField.isValid, let number = textField.cardParams.number, let code = textField.cardParams.cvc {
self.updated?(BotPaymentCardInputData(number: number, code: code, year: textField.cardParams.expYear, month: textField.cardParams.expMonth))
if code.count == 3 {
self.completed?()
}
} else {
self.updated?(nil)
}
}
func activateInput() {
self.cardField.becomeFirstResponder()
}
}
@@ -0,0 +1,25 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private let titleFont = Font.regular(17.0)
private func formatDate(_ value: Int32) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.YYYY"
return formatter.string(from: Date(timeIntervalSince1970: Double(value)))
}
final class BotPaymentDateItemNode: BotPaymentDisclosureItemNode {
var timestamp: Int32? {
didSet {
self.text = timestamp.flatMap({ formatDate($0) }) ?? ""
}
}
init(title: String, placeholder: String, timestamp: Int32?, strings: PresentationStrings) {
super.init(title: title, placeholder: placeholder, text: timestamp.flatMap({ formatDate($0) }) ?? "")
}
}
@@ -0,0 +1,130 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private let titleFont = Font.regular(17.0)
class BotPaymentDisclosureItemNode: BotPaymentItemNode {
private let title: String
private let placeholder: String
var text: String {
didSet {
if let theme = self.theme {
self.textNode.attributedText = NSAttributedString(string: self.text, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
}
}
}
private let highlightedBackgroundNode: ASDisplayNode
private let titleNode: ASTextNode
private let textNode: ASTextNode
private let buttonNode: HighlightTrackingButtonNode
private var theme: PresentationTheme?
var action: (() -> Void)?
init(title: String, placeholder: String, text: String) {
self.title = title
self.text = text
self.placeholder = placeholder
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.alpha = 0.0
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 1
self.buttonNode = HighlightTrackingButtonNode()
super.init(needsBackground: true)
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.buttonNode)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
if let supernode = strongSelf.supernode {
supernode.view.bringSubviewToFront(strongSelf.view)
}
strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.highlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
}
self.buttonNode.addTarget(self, action: #selector(buttonPressed), forControlEvents: .touchUpInside)
}
override func measureInset(theme: PresentationTheme, width: CGFloat) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.highlightedBackgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
self.titleNode.attributedText = NSAttributedString(string: self.title, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
if self.text.isEmpty {
self.textNode.attributedText = NSAttributedString(string: self.placeholder, font: titleFont, textColor: theme.list.itemPlaceholderTextColor)
} else {
self.textNode.attributedText = NSAttributedString(string: self.text, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
}
}
let leftInset: CGFloat = 16.0
let titleSize = self.titleNode.measure(CGSize(width: width - leftInset - 70.0, height: CGFloat.greatestFiniteMagnitude))
if titleSize.width.isZero {
return 0.0
} else {
return leftInset + titleSize.width + 17.0
}
}
override func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.highlightedBackgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
self.titleNode.attributedText = NSAttributedString(string: self.title, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
if self.text.isEmpty {
self.textNode.attributedText = NSAttributedString(string: self.placeholder, font: titleFont, textColor: theme.list.itemPlaceholderTextColor)
} else {
self.textNode.attributedText = NSAttributedString(string: self.text, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
}
}
let height: CGFloat = 52.0
self.buttonNode.frame = CGRect(origin: CGPoint(x: sideInset, y: 0.0), size: CGSize(width: width - sideInset * 2.0, height: height))
transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: height + UIScreenPixel)))
let leftInset: CGFloat = 16.0
let titleSize = self.titleNode.measure(CGSize(width: width - leftInset - 70.0 - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: 16.0), size: titleSize))
var textInset = leftInset
if !titleSize.width.isZero {
textInset += titleSize.width + 18.0
}
textInset = max(measuredInset, textInset)
let textSize = self.textNode.measure(CGSize(width: width - measuredInset - 8.0 - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: textInset + sideInset, y: 16.0), size: textSize))
return height
}
@objc func buttonPressed() {
self.action?()
}
}
@@ -0,0 +1,159 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private let titleFont = Font.regular(17.0)
enum BotPaymentFieldContentType {
case generic
case name
case asciiName
case phoneNumber
case email
case address
}
final class BotPaymentFieldItemNode: BotPaymentItemNode, UITextFieldDelegate {
private let title: String
var text: String {
get {
return self.textField.textField.text ?? ""
} set(value) {
self.textField.textField.text = value
}
}
private let contentType: BotPaymentFieldContentType
private let placeholder: String
private let titleNode: ASTextNode
private let textField: TextFieldNode
private var theme: PresentationTheme?
var focused: (() -> Void)?
var textUpdated: (() -> Void)?
var returnPressed: (() -> Void)?
init(title: String, placeholder: String, text: String = "", contentType: BotPaymentFieldContentType = .generic) {
self.title = title
self.placeholder = placeholder
self.contentType = contentType
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
self.textField = TextFieldNode()
self.textField.textField.font = titleFont
self.textField.textField.returnKeyType = .next
self.textField.textField.text = text
switch contentType {
case .generic:
break
case .name:
self.textField.textField.autocorrectionType = .no
self.textField.textField.keyboardType = .default
case .asciiName:
self.textField.textField.autocorrectionType = .no
self.textField.textField.keyboardType = .asciiCapable
case .address:
self.textField.textField.autocorrectionType = .no
case .phoneNumber:
self.textField.textField.keyboardType = .phonePad
if #available(iOSApplicationExtension 10.0, iOS 10.0, *) {
self.textField.textField.textContentType = .telephoneNumber
}
case .email:
self.textField.textField.keyboardType = .emailAddress
if #available(iOSApplicationExtension 10.0, iOS 10.0, *) {
self.textField.textField.textContentType = .emailAddress
}
}
super.init(needsBackground: true)
self.addSubnode(self.titleNode)
self.addSubnode(self.textField)
self.textField.textField.addTarget(self, action: #selector(self.editingChanged), for: [.editingChanged])
self.textField.textField.delegate = self
}
override func measureInset(theme: PresentationTheme, width: CGFloat) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.titleNode.attributedText = NSAttributedString(string: self.title, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
self.textField.textField.textColor = theme.list.itemPrimaryTextColor
self.textField.textField.attributedPlaceholder = NSAttributedString(string: placeholder, font: titleFont, textColor: theme.list.itemPlaceholderTextColor)
self.textField.textField.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance
self.textField.textField.tintColor = theme.list.itemAccentColor
}
let leftInset: CGFloat = 16.0
let titleSize = self.titleNode.measure(CGSize(width: width - leftInset - 70.0, height: CGFloat.greatestFiniteMagnitude))
if titleSize.width.isZero {
return 0.0
} else {
return leftInset + titleSize.width + 17.0
}
}
override func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.titleNode.attributedText = NSAttributedString(string: self.title, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
self.textField.textField.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance
self.textField.textField.tintColor = theme.list.itemAccentColor
}
let height: CGFloat = 52.0
let leftInset: CGFloat = 16.0
let titleSize = self.titleNode.measure(CGSize(width: width - leftInset - 70.0 - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: 16.0), size: titleSize))
var textInset = leftInset
if !titleSize.width.isZero {
textInset += titleSize.width + 18.0
}
textInset = max(measuredInset, textInset)
transition.updateFrame(node: self.textField, frame: CGRect(origin: CGPoint(x: textInset + sideInset, y: 5.0), size: CGSize(width: max(1.0, width - textInset - 8.0), height: 40.0)))
return height
}
func activateInput() {
self.textField.textField.becomeFirstResponder()
}
@objc func editingChanged() {
self.textUpdated?()
}
func textFieldDidBeginEditing(_ textField: UITextField) {
self.focused?()
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
self.returnPressed?()
return false
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if !string.isEmpty {
if case .name = self.contentType {
if let lowerBound = textField.position(from: textField.beginningOfDocument, offset: range.lowerBound), let upperBound = textField.position(from: textField.beginningOfDocument, offset: range.upperBound), let fieldRange = textField.textRange(from: lowerBound, to: upperBound) {
textField.replace(fieldRange, withText: string.uppercased())
self.editingChanged()
return false
}
}
}
return true
}
}
@@ -0,0 +1,39 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private let titleFont = Font.regular(14.0)
final class BotPaymentHeaderItemNode: BotPaymentItemNode {
private let text: String
private let textNode: ASTextNode
private var theme: PresentationTheme?
init(text: String) {
self.text = text
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 1
super.init(needsBackground: false)
self.addSubnode(self.textNode)
}
override func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.textNode.attributedText = NSAttributedString(string: self.text, font: titleFont, textColor: theme.list.sectionHeaderTextColor)
}
let leftInset: CGFloat = 16.0
let textSize = self.textNode.measure(CGSize(width: width - leftInset - 10.0 - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: 7.0), size: textSize))
return 30.0
}
}
@@ -0,0 +1,91 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private func botPaymentListHasRoundedBlockLayout(_ width: CGFloat) -> Bool {
return width >= 375.0
}
class BotPaymentItemNode: ASDisplayNode {
private let needsBackground: Bool
let backgroundNode: ASDisplayNode
private let topSeparatorNode: ASDisplayNode
private let bottomSeparatorNode: ASDisplayNode
private let maskNode: ASImageNode
private var theme: PresentationTheme?
init(needsBackground: Bool) {
self.needsBackground = needsBackground
self.backgroundNode = ASDisplayNode()
self.topSeparatorNode = ASDisplayNode()
self.bottomSeparatorNode = ASDisplayNode()
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
super.init()
if needsBackground {
self.addSubnode(self.backgroundNode)
self.addSubnode(self.topSeparatorNode)
self.addSubnode(self.bottomSeparatorNode)
}
}
func measureInset(theme: PresentationTheme, width: CGFloat) -> CGFloat {
return 0.0
}
final func updateLayout(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, previousItemNode: BotPaymentItemNode?, nextItemNode: BotPaymentItemNode?, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.backgroundNode.backgroundColor = theme.list.itemBlocksBackgroundColor
self.topSeparatorNode.backgroundColor = theme.list.itemBlocksSeparatorColor
self.bottomSeparatorNode.backgroundColor = theme.list.itemBlocksSeparatorColor
}
let height = self.layoutContents(theme: theme, width: width, sideInset: sideInset, measuredInset: measuredInset, transition: transition)
var topSeparatorInset: CGFloat = 0.0
if self.maskNode.supernode == nil {
self.addSubnode(self.maskNode)
}
let hasCorners = botPaymentListHasRoundedBlockLayout(width)
var hasTopCorners = false
var hasBottomCorners = false
if let previousItemNode = previousItemNode, previousItemNode.needsBackground {
topSeparatorInset = 16.0
} else {
hasTopCorners = true
self.topSeparatorNode.isHidden = hasCorners
}
if let nextItemNode = nextItemNode, nextItemNode.needsBackground {
self.bottomSeparatorNode.isHidden = true
} else {
hasBottomCorners = true
self.bottomSeparatorNode.isHidden = hasCorners
}
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(theme, top: hasTopCorners, bottom: hasBottomCorners, glass: true) : nil
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)))
transition.updateFrame(node: self.maskNode, frame: self.backgroundNode.frame.insetBy(dx: sideInset, dy: 0.0))
transition.updateFrame(node: self.topSeparatorNode, frame: CGRect(origin: CGPoint(x: topSeparatorInset + sideInset, y: 0.0), size: CGSize(width: width - topSeparatorInset - sideInset - sideInset, height: UIScreenPixel)))
transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset - sideInset, height: UIScreenPixel)))
return height
}
func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
return 0.0
}
}
@@ -0,0 +1,90 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private let titleFont = Font.regular(17.0)
final class BotPaymentSwitchItemNode: BotPaymentItemNode {
private let toggled: (Bool) -> Void
private let title: String
private let titleNode: ASTextNode
private let switchNode: SwitchNode
private let buttonNode: HighlightableButtonNode
private var theme: PresentationTheme?
var canBeSwitched: Bool {
didSet {
}
}
var isOn: Bool {
get {
return self.switchNode.isOn
} set(value) {
if self.switchNode.isOn != value {
self.switchNode.setOn(value, animated: true)
}
}
}
init(title: String, isOn: Bool, canBeSwitched: Bool = true, toggled: @escaping (Bool) -> Void = { _ in }) {
self.title = title
self.canBeSwitched = canBeSwitched
self.toggled = toggled
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
self.switchNode = SwitchNode()
self.switchNode.setOn(isOn, animated: false)
self.buttonNode = HighlightableButtonNode()
super.init(needsBackground: true)
self.addSubnode(self.titleNode)
self.addSubnode(self.switchNode)
self.addSubnode(self.buttonNode)
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
if canBeSwitched {
self.switchNode.isUserInteractionEnabled = true
self.buttonNode.isUserInteractionEnabled = false
} else {
self.switchNode.isUserInteractionEnabled = false
self.buttonNode.isUserInteractionEnabled = true
}
}
override func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.titleNode.attributedText = NSAttributedString(string: self.title, font: titleFont, textColor: theme.list.itemPrimaryTextColor)
self.switchNode.frameColor = theme.list.itemSwitchColors.frameColor
self.switchNode.contentColor = theme.list.itemSwitchColors.contentColor
self.switchNode.handleColor = theme.list.itemSwitchColors.handleColor
}
let leftInset: CGFloat = 16.0
let titleSize = self.titleNode.measure(CGSize(width: width - leftInset - 70.0 - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: 16.0), size: titleSize))
let switchSize = self.switchNode.measure(CGSize(width: 100.0, height: 100.0))
let switchFrame = CGRect(origin: CGPoint(x: width - switchSize.width - 15.0 - sideInset, y: 12.0), size: switchSize)
transition.updateFrame(node: self.switchNode, frame: switchFrame)
transition.updateFrame(node: self.buttonNode, frame: switchFrame)
return 52.0
}
@objc private func buttonPressed() {
self.toggled(!self.isOn)
}
}
@@ -0,0 +1,40 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
private let textFont = Font.regular(14.0)
final class BotPaymentTextItemNode: BotPaymentItemNode {
private let text: String
private let textNode: ASTextNode
private var theme: PresentationTheme?
init(text: String) {
self.text = text
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 0
super.init(needsBackground: false)
self.addSubnode(self.textNode)
}
override func layoutContents(theme: PresentationTheme, width: CGFloat, sideInset: CGFloat, measuredInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
if self.theme !== theme {
self.theme = theme
self.textNode.attributedText = NSAttributedString(string: self.text, font: textFont, textColor: theme.list.sectionHeaderTextColor)
}
let leftInset: CGFloat = 16.0
let textSize = self.textNode.measure(CGSize(width: width - leftInset - 10.0 - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: leftInset + sideInset, y: 7.0), size: textSize))
return textSize.height + 7.0 + 7.0
}
}
@@ -0,0 +1,82 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
public final class BotReceiptController: ViewController {
private var controllerNode: BotReceiptControllerNode {
return self.displayNode as! BotReceiptControllerNode
}
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private let context: AccountContext
private let messageId: EngineMessage.Id
private var presentationData: PresentationData
private var didPlayPresentationAnimation = false
public init(context: AccountContext, messageId: EngineMessage.Id) {
self.context = context
self.messageId = messageId
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
let title = self.presentationData.strings.Checkout_Receipt_Title
/*if invoice.flags.contains(.isTest) {
title += " (Test)"
}*/
self.title = title
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func loadDisplayNode() {
let displayNode = BotReceiptControllerNode(controller: nil, navigationBar: self.navigationBar!, context: self.context, messageId: self.messageId, dismissAnimated: { [weak self] in
self?.dismiss()
})
displayNode.dismiss = { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.displayNode = displayNode
super.displayNodeDidLoad()
self._ready.set(displayNode.ready)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments, !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true
if case .modalSheet = presentationArguments.presentationAnimation {
self.controllerNode.animateIn()
}
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition, additionalInsets: UIEdgeInsets())
}
@objc private func cancelPressed() {
self.dismiss()
}
}
@@ -0,0 +1,357 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import TelegramStringFormatting
final class BotReceiptControllerArguments {
fileprivate let account: Account
fileprivate let source: BotPaymentInvoiceSource
fileprivate init(account: Account, source: BotPaymentInvoiceSource) {
self.account = account
self.source = source
}
}
private enum BotReceiptSection: Int32 {
case header
case prices
case info
}
enum BotReceiptEntry: ItemListNodeEntry {
case header(PresentationTheme, TelegramMediaInvoice, String)
case price(Int, PresentationTheme, String, String, Bool, Bool)
case paymentMethod(PresentationTheme, String, String)
case shippingInfo(PresentationTheme, String, String)
case shippingMethod(PresentationTheme, String, String)
case nameInfo(PresentationTheme, String, String)
case emailInfo(PresentationTheme, String, String)
case phoneInfo(PresentationTheme, String, String)
var section: ItemListSectionId {
switch self {
case .header:
return BotReceiptSection.prices.rawValue
case .price:
return BotReceiptSection.prices.rawValue
default:
return BotReceiptSection.info.rawValue
}
}
var stableId: Int32 {
switch self {
case .header:
return 0
case let .price(index, _, _, _, _, _):
return 1 + Int32(index)
case .paymentMethod:
return 10000 + 0
case .shippingInfo:
return 10000 + 1
case .shippingMethod:
return 10000 + 2
case .nameInfo:
return 10000 + 3
case .emailInfo:
return 10000 + 4
case .phoneInfo:
return 10000 + 5
}
}
static func ==(lhs: BotReceiptEntry, rhs: BotReceiptEntry) -> Bool {
switch lhs {
case let .header(lhsTheme, lhsInvoice, lhsName):
if case let .header(rhsTheme, rhsInvoice, rhsName) = rhs {
if lhsTheme !== rhsTheme {
return false
}
if !lhsInvoice.isEqual(to: rhsInvoice) {
return false
}
if lhsName != rhsName {
return false
}
return true
} else {
return false
}
case let .price(lhsIndex, lhsTheme, lhsText, lhsValue, lhsHasSeparator, lhsFinal):
if case let .price(rhsIndex, rhsTheme, rhsText, rhsValue, rhsHasSeparator, rhsFinal) = rhs {
if lhsIndex != rhsIndex {
return false
}
if lhsTheme !== rhsTheme {
return false
}
if lhsText != rhsText {
return false
}
if lhsValue != rhsValue {
return false
}
if lhsHasSeparator != rhsHasSeparator {
return false
}
if lhsFinal != rhsFinal {
return false
}
return true
} else {
return false
}
case let .paymentMethod(lhsTheme, lhsText, lhsValue):
if case let .paymentMethod(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .shippingInfo(lhsTheme, lhsText, lhsValue):
if case let .shippingInfo(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .shippingMethod(lhsTheme, lhsText, lhsValue):
if case let .shippingMethod(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .nameInfo(lhsTheme, lhsText, lhsValue):
if case let .nameInfo(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .emailInfo(lhsTheme, lhsText, lhsValue):
if case let .emailInfo(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .phoneInfo(lhsTheme, lhsText, lhsValue):
if case let .phoneInfo(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
}
}
static func <(lhs: BotReceiptEntry, rhs: BotReceiptEntry) -> Bool {
return lhs.stableId < rhs.stableId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let arguments = arguments as! BotReceiptControllerArguments
switch self {
case let .header(theme, invoice, botName):
return BotCheckoutHeaderItem(account: arguments.account, theme: theme, invoice: invoice, source: arguments.source, botName: botName, sectionId: self.section)
case let .price(_, theme, text, value, hasSeparator, isFinal):
return BotCheckoutPriceItem(theme: theme, title: text, label: value, isFinal: isFinal, hasSeparator: hasSeparator, shimmeringIndex: nil, sectionId: self.section)
case let .paymentMethod(_, text, value):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil)
case let .shippingInfo(_, text, value):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil)
case let .shippingMethod(_, text, value):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil)
case let .nameInfo(_, text, value):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil)
case let .emailInfo(_, text, value):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil)
case let .phoneInfo(_, text, value):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil)
}
}
}
private func botReceiptControllerEntries(presentationData: PresentationData, invoice: TelegramMediaInvoice?, formInvoice: BotPaymentInvoice?, formInfo: BotPaymentRequestedInfo?, shippingOption: BotPaymentShippingOption?, paymentMethodTitle: String?, botPeer: EnginePeer?, tipAmount: Int64?) -> [BotReceiptEntry] {
var entries: [BotReceiptEntry] = []
var botName = ""
if let botPeer = botPeer {
botName = botPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
}
if let invoice = invoice {
entries.append(.header(presentationData.theme, invoice, botName))
}
if let formInvoice = formInvoice {
var totalPrice: Int64 = 0
var index = 0
for price in formInvoice.prices {
entries.append(.price(index, presentationData.theme, price.label, formatCurrencyAmount(price.amount, currency: formInvoice.currency), index == 0, false))
totalPrice += price.amount
index += 1
}
var shippingOptionString: String?
if let shippingOption = shippingOption {
shippingOptionString = shippingOption.title
for price in shippingOption.prices {
entries.append(.price(index, presentationData.theme, price.label, formatCurrencyAmount(price.amount, currency: formInvoice.currency), index == 0, false))
totalPrice += price.amount
index += 1
}
}
if let tipAmount = tipAmount, tipAmount != 0 {
entries.append(.price(index, presentationData.theme, presentationData.strings.Checkout_TipItem, formatCurrencyAmount(tipAmount, currency: formInvoice.currency), index == 0, false))
totalPrice += tipAmount
index += 1
}
entries.append(.price(index, presentationData.theme, presentationData.strings.Checkout_TotalAmount, formatCurrencyAmount(totalPrice, currency: formInvoice.currency), true, true))
if let paymentMethodTitle = paymentMethodTitle {
entries.append(.paymentMethod(presentationData.theme, presentationData.strings.Checkout_PaymentMethod, paymentMethodTitle))
}
if formInvoice.requestedFields.contains(.shippingAddress) {
var addressString = ""
if let address = formInfo?.shippingAddress {
let components: [String] = [
address.city,
address.streetLine1,
address.streetLine2,
address.state
]
for component in components {
if !component.isEmpty {
if !addressString.isEmpty {
addressString.append(", ")
}
addressString.append(component)
}
}
}
entries.append(.shippingInfo(presentationData.theme, presentationData.strings.Checkout_ShippingAddress, addressString))
if let shippingOptionString = shippingOptionString {
entries.append(.shippingMethod(presentationData.theme, presentationData.strings.Checkout_ShippingMethod, shippingOptionString))
}
}
if formInvoice.requestedFields.contains(.name) {
entries.append(.nameInfo(presentationData.theme, presentationData.strings.Checkout_Name, formInfo?.name ?? ""))
}
if formInvoice.requestedFields.contains(.email) {
entries.append(.emailInfo(presentationData.theme, presentationData.strings.Checkout_Email, formInfo?.email ?? ""))
}
if formInvoice.requestedFields.contains(.phone) {
entries.append(.phoneInfo(presentationData.theme, presentationData.strings.Checkout_Phone, formInfo?.phone ?? ""))
}
}
return entries
}
private func availablePaymentMethods(current: BotCheckoutPaymentMethod?) -> [BotCheckoutPaymentMethod] {
if let current = current {
return [current]
}
return []
}
final class BotReceiptControllerNode: ItemListControllerNode {
private let context: AccountContext
private let dismissAnimated: () -> Void
private var presentationData: PresentationData
private let receiptData = Promise<(BotPaymentInvoice, BotPaymentRequestedInfo?, BotPaymentShippingOption?, String?, TelegramMediaInvoice, Int64?)?>(nil)
private var dataRequestDisposable: Disposable?
private let actionButtonPanelNode: ASDisplayNode
private let actionButtonPanelSeparator: ASDisplayNode
private let actionButton: BotCheckoutActionButton
init(controller: ItemListController?, navigationBar: NavigationBar, context: AccountContext, messageId: EngineMessage.Id, dismissAnimated: @escaping () -> Void) {
self.context = context
self.dismissAnimated = dismissAnimated
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
let arguments = BotReceiptControllerArguments(account: context.account, source: .message(messageId))
let signal: Signal<(ItemListPresentationData, (ItemListNodeState, Any)), NoError> = combineLatest(
context.sharedContext.presentationData,
receiptData.get(),
context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId)
)
)
|> map { presentationData, receiptData, botPeer -> (ItemListPresentationData, (ItemListNodeState, Any)) in
let nodeState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: botReceiptControllerEntries(presentationData: presentationData, invoice: receiptData?.4, formInvoice: receiptData?.0, formInfo: receiptData?.1, shippingOption: receiptData?.2, paymentMethodTitle: receiptData?.3, botPeer: botPeer, tipAmount: receiptData?.5), style: .blocks, focusItemTag: nil, emptyStateItem: nil, animateChanges: false)
return (ItemListPresentationData(presentationData), (nodeState, arguments))
}
self.actionButtonPanelNode = ASDisplayNode()
self.actionButtonPanelNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.opaqueBackgroundColor
self.actionButtonPanelSeparator = ASDisplayNode()
self.actionButtonPanelSeparator.backgroundColor = self.presentationData.theme.rootController.navigationBar.separatorColor
self.actionButton = BotCheckoutActionButton(activeFillColor: self.presentationData.theme.list.itemAccentColor, inactiveFillColor: self.presentationData.theme.list.itemDisabledTextColor, foregroundColor: self.presentationData.theme.list.plainBackgroundColor)
self.actionButton.setState(.active(text: self.presentationData.strings.Common_Done, isEnabled: true))
super.init(controller: controller, navigationBar: navigationBar, state: signal)
self.dataRequestDisposable = (context.engine.payments.requestBotPaymentReceipt(messageId: messageId) |> deliverOnMainQueue).start(next: { [weak self] receipt in
if let strongSelf = self {
UIView.transition(with: strongSelf.view, duration: 0.25, options: UIView.AnimationOptions.transitionCrossDissolve, animations: {
}, completion: nil)
strongSelf.receiptData.set(.single((receipt.invoice, receipt.info, receipt.shippingOption, receipt.credentialsTitle, receipt.invoiceMedia, receipt.tipAmount)))
}
}).strict()
self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside)
self.addSubnode(self.actionButtonPanelNode)
self.actionButtonPanelNode.addSubnode(self.actionButtonPanelSeparator)
self.actionButtonPanelNode.addSubnode(self.actionButton)
}
deinit {
self.dataRequestDisposable?.dispose()
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition, additionalInsets: UIEdgeInsets) {
var updatedInsets = layout.intrinsicInsets
let bottomPanelHorizontalInset: CGFloat = 30.0
let bottomPanelVerticalInset: CGFloat = 16.0
let bottomPanelHeight = max(updatedInsets.bottom, layout.inputHeight ?? 0.0) + bottomPanelVerticalInset * 2.0 + BotCheckoutActionButton.height
transition.updateFrame(node: self.actionButtonPanelNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - bottomPanelHeight), size: CGSize(width: layout.size.width, height: bottomPanelHeight)))
transition.updateFrame(node: self.actionButtonPanelSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: UIScreenPixel)))
let actionButtonFrame = CGRect(origin: CGPoint(x: bottomPanelHorizontalInset, y: bottomPanelVerticalInset), size: CGSize(width: layout.size.width - bottomPanelHorizontalInset * 2.0, height: BotCheckoutActionButton.height))
transition.updateFrame(node: self.actionButton, frame: actionButtonFrame)
self.actionButton.updateLayout(absoluteRect: actionButtonFrame.offsetBy(dx: self.actionButtonPanelNode.frame.minX, dy: self.actionButtonPanelNode.frame.minY), containerSize: layout.size, transition: transition)
updatedInsets.bottom = bottomPanelHeight
super.containerLayoutUpdated(ContainerViewLayout(size: layout.size, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: updatedInsets, safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: layout.statusBarHeight, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver), navigationBarHeight: navigationBarHeight, transition: transition, additionalInsets: additionalInsets)
}
@objc func actionButtonPressed() {
self.dismissAnimated()
}
}
@@ -0,0 +1,178 @@
//
// CurrencyCode.swift
// CurrencyText
//
// Created by Felipe Lefèvre Marino on 1/26/19.
//
import Foundation
/// Currency wraps all availabe currencies that can represented as formatted monetary values
/// A currency code is a three-letter code that is, in most cases,
/// composed of a countrys two-character Internet country code plus an extra character
/// to denote the currency unit. For example, the currency code for the Australian
/// dollar is AUD. Currency codes are based on the ISO 4217 standard
public enum Currency: String {
case afghani = "AFN",
algerianDinar = "DZD",
argentinePeso = "ARS",
armenianDram = "AMD",
arubanFlorin = "AWG",
australianDollar = "AUD",
azerbaijanManat = "AZN",
bahamianDollar = "BSD",
bahrainiDinar = "BHD",
baht = "THB",
balboa = "PAB",
barbadosDollar = "BBD",
belarusianRuble = "BYN",
belizeDollar = "BZD",
bermudianDollar = "BMD",
boliviano = "BOB",
bolívar = "VEF",
brazilianReal = "BRL",
bruneiDollar = "BND",
bulgarianLev = "BGN",
burundiFranc = "BIF",
caboVerdeEscudo = "CVE",
canadianDollar = "CAD",
caymanIslandsDollar = "KYD",
chileanPeso = "CLP",
colombianPeso = "COP",
comorianFranc = "KMF",
congoleseFranc = "CDF",
convertibleMark = "BAM",
cordobaOro = "NIO",
costaRicanColon = "CRC",
cubanPeso = "CUP",
czechKoruna = "CZK",
dalasi = "GMD",
danishKrone = "DKK",
denar = "MKD",
djiboutiFranc = "DJF",
dobra = "STN",
dollar = "USD",
dominicanPeso = "DOP",
dong = "VND",
eastCaribbeanDollar = "XCD",
egyptianPound = "EGP",
elSalvadorColon = "SVC",
ethiopianBirr = "ETB",
euro = "EUR",
falklandIslandsPound = "FKP",
fijiDollar = "FJD",
forint = "HUF",
ghanaCedi = "GHS",
gibraltarPound = "GIP",
gourde = "HTG",
guarani = "PYG",
guineanFranc = "GNF",
guyanaDollar = "GYD",
hongKongDollar = "HKD",
hryvnia = "UAH",
icelandKrona = "ISK",
indianRupee = "INR",
iranianRial = "IRR",
iraqiDinar = "IQD",
jamaicanDollar = "JMD",
jordanianDinar = "JOD",
kenyanShilling = "KES",
kina = "PGK",
kuna = "HRK",
kuwaitiDinar = "KWD",
kwanza = "AOA",
kyat = "MMK",
laoKip = "LAK",
lari = "GEL",
lebanesePound = "LBP",
lek = "ALL",
lempira = "HNL",
leone = "SLL",
liberianDollar = "LRD",
libyanDinar = "LYD",
lilangeni = "SZL",
loti = "LSL",
malagasyAriary = "MGA",
malawiKwacha = "MWK",
malaysianRinggit = "MYR",
mauritiusRupee = "MUR",
mexicanPeso = "MXN",
mexicanUnidadDeInversion = "MXV",
moldovanLeu = "MDL",
moroccanDirham = "MAD",
mozambiqueMetical = "MZN",
mvdol = "BOV",
naira = "NGN",
nakfa = "ERN",
namibiaDollar = "NAD",
nepaleseRupee = "NPR",
netherlandsAntilleanGuilder = "ANG",
newIsraeliSheqel = "ILS",
newTaiwanDollar = "TWD",
newZealandDollar = "NZD",
ngultrum = "BTN",
northKoreanWon = "KPW",
norwegianKrone = "NOK",
ouguiya = "MRU",
paanga = "TOP",
pakistanRupee = "PKR",
pataca = "MOP",
pesoConvertible = "CUC",
pesoUruguayo = "UYU",
philippinePiso = "PHP",
poundSterling = "GBP",
pula = "BWP",
qatariRial = "QAR",
quetzal = "GTQ",
rand = "ZAR",
rialOmani = "OMR",
riel = "KHR",
romanianLeu = "RON",
rufiyaa = "MVR",
rupiah = "IDR",
russianRuble = "RUB",
rwandaFranc = "RWF",
saintHelenaPound = "SHP",
saudiRiyal = "SAR",
serbianDinar = "RSD",
seychellesRupee = "SCR",
singaporeDollar = "SGD",
sol = "PEN",
solomonIslandsDollar = "SBD",
som = "KGS",
somaliShilling = "SOS",
somoni = "TJS",
southSudanesePound = "SSP",
sriLankaRupee = "LKR",
sudanesePound = "SDG",
surinamDollar = "SRD",
swedishKrona = "SEK",
swissFranc = "CHF",
syrianPound = "SYP",
taka = "BDT",
tala = "WST",
tanzanianShilling = "TZS",
tenge = "KZT",
trinidadAndTobagoDollar = "TTD",
tugrik = "MNT",
tunisianDinar = "TND",
turkishLira = "TRY",
turkmenistanNewManat = "TMT",
uaeDirham = "AED",
ugandaShilling = "UGX",
unidadDeFomento = "CLF",
unidadDeValorReal = "COU",
uruguayPesoEnUnidadesIndexadas = "UYI",
uzbekistanSum = "UZS",
vatu = "VUV",
wirEuro = "CHE",
wirFranc = "CHW",
won = "KRW",
yemeniRial = "YER",
yen = "JPY",
yuanRenminbi = "CNY",
zambianKwacha = "ZMW",
zimbabweDollar = "ZWL",
zloty = "PLN",
none
}
@@ -0,0 +1,345 @@
//
// CurrencyFormatter.swift
// CurrencyText
//
// Created by Felipe Lefèvre Marino on 1/27/19.
//
import Foundation
import TelegramStringFormatting
// MARK: - Currency protocols
public protocol CurrencyFormatting {
var maxDigitsCount: Int { get }
var decimalDigits: Int { get set }
var maxValue: Double? { get set }
var minValue: Double? { get set }
var initialText: String { get }
var currencySymbol: String { get set }
func string(from double: Double) -> String?
func unformatted(string: String) -> String?
func double(from string: String) -> Double?
}
public protocol CurrencyAdjusting {
func formattedStringWithAdjustedDecimalSeparator(from string: String) -> String?
func formattedStringAdjustedToFitAllowedValues(from string: String) -> String?
}
// MARK: - Currency formatter
public class CurrencyFormatter: CurrencyFormatting {
/// Set the locale to retrieve the currency from
/// You can pass a Swift type Locale or one of the
/// Locales enum options - that encapsulates all available locales.
public var locale: LocaleConvertible {
set { self.numberFormatter.locale = newValue.locale }
get { self.numberFormatter.locale }
}
/// Set the desired currency type
/// * Note: The currency take effetcs above the displayed currency symbol,
/// however details such as decimal separators, grouping separators and others
/// will be set based on the defined locale. So for a precise experience, please
/// preferarbly setup both, when you are setting a currency that does not match the
/// default/current user locale.
public var currency: Currency {
set { numberFormatter.currencyCode = newValue.rawValue }
get { Currency(rawValue: numberFormatter.currencyCode) ?? .dollar }
}
/// Define if currency symbol should be presented or not.
/// Note: when set to false the current currency symbol is removed
public var showCurrencySymbol: Bool = true {
didSet {
numberFormatter.currencySymbol = showCurrencySymbol ? numberFormatter.currencySymbol : ""
}
}
/// The currency's symbol.
/// Can be used to read or set a custom symbol.
/// Note: showCurrencySymbol must be set to true for
/// the currencySymbol to be correctly changed.
public var currencySymbol: String {
set {
guard showCurrencySymbol else { return }
numberFormatter.currencySymbol = newValue
}
get { numberFormatter.currencySymbol }
}
/// The lowest number allowed as input.
/// This value is initially set to the text field text
/// when defined.
public var minValue: Double? {
set {
guard let newValue = newValue else { return }
numberFormatter.minimum = NSNumber(value: newValue)
}
get {
if let minValue = numberFormatter.minimum {
return Double(truncating: minValue)
}
return nil
}
}
/// The highest number allowed as input.
/// The text field will not allow the user to increase the input
/// value beyond it, when defined.
public var maxValue: Double? {
set {
guard let newValue = newValue else { return }
numberFormatter.maximum = NSNumber(value: newValue)
}
get {
if let maxValue = numberFormatter.maximum {
return Double(truncating: maxValue)
}
return nil
}
}
/// The number of decimal digits shown.
/// default is set to zero.
/// * Example: With decimal digits set to 3, if the value to represent is "1",
/// the formatted text in the fractions will be ",001".
/// Other than that with the value as 1, the formatted text fractions will be ",1".
public var decimalDigits: Int {
set {
numberFormatter.minimumFractionDigits = newValue
numberFormatter.maximumFractionDigits = newValue
}
get { numberFormatter.minimumFractionDigits }
}
/// Set decimal numbers behavior.
/// When set to true decimalDigits are automatically set to 2 (most currencies pattern),
/// and the decimal separator is presented. Otherwise decimal digits are not shown and
/// the separator gets hidden as well
/// When reading it returns the current pattern based on the setup.
/// Note: Setting decimal digits after, or alwaysShowsDecimalSeparator can overlap this definitios,
/// and should be only done if you need specific cases
public var hasDecimals: Bool {
set {
self.decimalDigits = newValue ? 2 : 0
self.numberFormatter.alwaysShowsDecimalSeparator = newValue ? true : false
}
get { decimalDigits != 0 }
}
/// Defines the string that is the decimal separator
/// Note: only presented when hasDecimals is true OR decimalDigits
/// is greater than 0.
public var decimalSeparator: String {
set { self.numberFormatter.currencyDecimalSeparator = newValue }
get { numberFormatter.currencyDecimalSeparator }
}
/// Can be used to set a custom currency code string
public var currencyCode: String {
set { self.numberFormatter.currencyCode = newValue }
get { numberFormatter.currencyCode }
}
/// Sets if decimal separator should always be presented,
/// even when decimal digits are disabled
public var alwaysShowsDecimalSeparator: Bool {
set { self.numberFormatter.alwaysShowsDecimalSeparator = newValue }
get { numberFormatter.alwaysShowsDecimalSeparator }
}
/// The amount of grouped numbers. This definition is fixed for at least
/// the first non-decimal group of numbers, and is applied to all other
/// groups if secondaryGroupingSize does not have another value.
public var groupingSize: Int {
set { self.numberFormatter.groupingSize = newValue }
get { numberFormatter.groupingSize }
}
/// The amount of grouped numbers after the first group.
/// Example: for the given value of 99999999999, when grouping size
/// is set to 3 and secondaryGroupingSize has 4 as value,
/// the number is represented as: (9999) (9999) [999].
/// Beign [] grouping size and () secondary grouping size.
public var secondaryGroupingSize: Int {
set { self.numberFormatter.secondaryGroupingSize = newValue }
get { numberFormatter.secondaryGroupingSize }
}
/// Defines the string that is shown between groups of numbers
/// * Example: a monetary value of a thousand (1000) with a grouping
/// separator == "." is represented as `1.000` *.
/// Note: It automatically sets hasGroupingSeparator to true.
public var groupingSeparator: String {
set {
self.numberFormatter.currencyGroupingSeparator = newValue
self.numberFormatter.usesGroupingSeparator = true
}
get { self.numberFormatter.currencyGroupingSeparator }
}
/// Sets if has separator between all group of numbers.
/// * Example: when set to false, a bug number such as a million
/// is represented by tight numbers "1000000". Otherwise if set
/// to true each group is separated by the defined `groupingSeparator`. *
/// Note: When set to true only works by defining a grouping separator.
public var hasGroupingSeparator: Bool {
set { self.numberFormatter.usesGroupingSeparator = newValue }
get { self.numberFormatter.usesGroupingSeparator }
}
/// Value that will be presented when the text field
/// text values matches zero (0)
public var zeroSymbol: String? {
set { numberFormatter.zeroSymbol = newValue }
get { numberFormatter.zeroSymbol }
}
/// Value that will be presented when the text field
/// is empty. The default is "" - empty string
public var nilSymbol: String {
set { numberFormatter.nilSymbol = newValue }
get { return numberFormatter.nilSymbol }
}
/// Encapsulated Number formatter
let numberFormatter: NumberFormatter
/// Maximum allowed number of integers
public var maxIntegers: Int? {
set {
guard let maxIntegers = newValue else { return }
numberFormatter.maximumIntegerDigits = maxIntegers
}
get { return numberFormatter.maximumIntegerDigits }
}
/// Returns the maximum allowed number of numerical characters
public var maxDigitsCount: Int {
numberFormatter.maximumIntegerDigits + numberFormatter.maximumFractionDigits
}
/// The value zero formatted to serve as initial text.
public var initialText: String {
numberFormatter.string(from: 0) ?? "0.0"
}
//MARK: - INIT
/// Handler to initialize a new style.
public typealias InitHandler = ((CurrencyFormatter) -> (Void))
/// Initialize a new currency formatter with optional configuration handler callback.
///
/// - Parameter handler: configuration handler callback.
public init(currency: String, _ handler: InitHandler? = nil) {
numberFormatter = setupCurrencyNumberFormatter(currency: currency)
numberFormatter.alwaysShowsDecimalSeparator = false
/*numberFormatter.numberStyle = .currency
numberFormatter.minimumFractionDigits = 2
numberFormatter.maximumFractionDigits = 2
numberFormatter.minimumIntegerDigits = 1*/
handler?(self)
}
}
// MARK: Format
extension CurrencyFormatter {
/// Returns a currency string from a given double value.
///
/// - Parameter double: the monetary amount.
/// - Returns: formatted currency string.
public func string(from double: Double) -> String? {
let validValue = valueAdjustedToFitAllowedValues(from: double)
return numberFormatter.string(from: validValue)
}
/// Returns a double from a string that represents a numerical value.
///
/// - Parameter string: string that describes the numerical value.
/// - Returns: the value as a Double.
public func double(from string: String) -> Double? {
Double(string)
}
/// Receives a currency formatted string and returns its
/// numerical/unformatted representation.
///
/// - Parameter string: currency formatted string
/// - Returns: numerical representation
public func unformatted(string: String) -> String? {
string.numeralFormat()
}
}
// MARK: - Currency adjusting conformance
extension CurrencyFormatter: CurrencyAdjusting {
/// Receives a currency formatted String, and returns it with its decimal separator adjusted.
///
/// _Note_: Useful when appending values to a currency formatted String.
/// E.g. "$ 23.24" after users taps an additional number, is equal = "$ 23.247".
/// Which gets updated to "$ 232.47".
///
/// - Parameter string: The currency formatted String
/// - Returns: The currency formatted received String with its decimal separator adjusted
public func formattedStringWithAdjustedDecimalSeparator(from string: String) -> String? {
let adjustedString = numeralStringWithAdjustedDecimalSeparator(from: string)
guard let value = double(from: adjustedString) else { return nil }
return self.numberFormatter.string(from: value)
}
/// Receives a currency formatted String, and returns it to fit the formatter's min and max values, when needed.
///
/// - Parameter string: The currency formatted String
/// - Returns: The currency formatted String, or the formatted version of its closes allowed value, min or max, depending on the closest boundary.
public func formattedStringAdjustedToFitAllowedValues(from string: String) -> String? {
let adjustedString = numeralStringWithAdjustedDecimalSeparator(from: string)
guard let originalValue = double(from: adjustedString) else { return nil }
return self.string(from: originalValue)
}
/// Receives a currency formatted String, and returns a numeral version of it with its decimal separator adjusted.
///
/// E.g. "$ 23.24", after users taps an additional number, get equal as "$ 23.247". The returned value would be "232.47".
///
/// - Parameter string: The currency formatted String
/// - Returns: The received String with numeral format and with its decimal separator adjusted
private func numeralStringWithAdjustedDecimalSeparator(from string: String) -> String {
var updatedString = string.numeralFormat()
let isNegative: Bool = string.contains(String.negativeSymbol)
updatedString = isNegative ? .negativeSymbol + updatedString : updatedString
updatedString.updateDecimalSeparator(decimalDigits: decimalDigits)
return updatedString
}
/// Receives a Double value, and returns it adjusted to fit min and max allowed values, when needed.
/// If the value respect number formatter's min and max, it will be returned without changes.
///
/// - Parameter value: The value to be adjusted if needed
/// - Returns: The value updated or not, depending on the formatter's settings
private func valueAdjustedToFitAllowedValues(from value: Double) -> Double {
if let minValue = minValue, value < minValue {
return minValue
} else if let maxValue = maxValue, value > maxValue {
return maxValue
}
return value
}
}
@@ -0,0 +1,755 @@
//
// CurrencyLocale.swift
// CurrencyText
//
// Created by Felipe Lefèvre Marino on 1/26/19.
//
import Foundation
/// All locales were extracted from:
/// jacobbubu/ioslocaleidentifiers.csv - https://gist.github.com/jacobbubu/1836273
/// The LocaleConvertible pattern is inspired in SwiftDate by malcommac
/// https://github.com/malcommac/SwiftDate
/// LocaleConvertible defines the behavior to convert locale info to system Locale type
public protocol LocaleConvertible {
var locale: Locale { get }
}
extension Locale: LocaleConvertible {
public var locale: Locale { return self }
}
/// Defines locales available in system
public enum CurrencyLocale: String, LocaleConvertible {
case current = "current"
case autoUpdating = "currentAutoUpdating"
case afrikaans = "af"
case afrikaansNamibia = "af_NA"
case afrikaansSouthAfrica = "af_ZA"
case aghem = "agq"
case aghemCameroon = "agq_CM"
case akan = "ak"
case akanGhana = "ak_GH"
case albanian = "sq"
case albanianAlbania = "sq_AL"
case albanianKosovo = "sq_XK"
case albanianMacedonia = "sq_MK"
case amharic = "am"
case amharicEthiopia = "am_ET"
case arabic = "ar"
case arabicAlgeria = "ar_DZ"
case arabicBahrain = "ar_BH"
case arabicChad = "ar_TD"
case arabicComoros = "ar_KM"
case arabicDjibouti = "ar_DJ"
case arabicEgypt = "ar_EG"
case arabicEritrea = "ar_ER"
case arabicIraq = "ar_IQ"
case arabicIsrael = "ar_IL"
case arabicJordan = "ar_JO"
case arabicKuwait = "ar_KW"
case arabicLebanon = "ar_LB"
case arabicLibya = "ar_LY"
case arabicMauritania = "ar_MR"
case arabicMorocco = "ar_MA"
case arabicOman = "ar_OM"
case arabicPalestinianTerritories = "ar_PS"
case arabicQatar = "ar_QA"
case arabicSaudiArabia = "ar_SA"
case arabicSomalia = "ar_SO"
case arabicSouthSudan = "ar_SS"
case arabicSudan = "ar_SD"
case arabicSyria = "ar_SY"
case arabicTunisia = "ar_TN"
case arabicUnitedArabEmirates = "ar_AE"
case arabicWesternSahara = "ar_EH"
case arabicWorld = "ar_001"
case arabicYemen = "ar_YE"
case armenian = "hy"
case armenianArmenia = "hy_AM"
case assamese = "as"
case assameseIndia = "as_IN"
case asu = "asa"
case asuTanzania = "asa_TZ"
case azerbaijani = "az_Latn"
case azerbaijaniAzerbaijan = "az_Latn_AZ"
case azerbaijaniCyrillic = "az_Cyrl"
case azerbaijaniCyrillicAzerbaijan = "az_Cyrl_AZ"
case bafia = "ksf"
case bafiaCameroon = "ksf_CM"
case bambara = "bm_Latn"
case bambaraMali = "bm_Latn_ML"
case basaa = "bas"
case basaaCameroon = "bas_CM"
case basque = "eu"
case basqueSpain = "eu_ES"
case belarusian = "be"
case belarusianBelarus = "be_BY"
case bemba = "bem"
case bembaZambia = "bem_ZM"
case bena = "bez"
case benaTanzania = "bez_TZ"
case bengali = "bn"
case bengaliBangladesh = "bn_BD"
case engaliIndia = "bn_IN"
case bodo = "brx"
case bodoIndia = "brx_IN"
case bosnian = "bs_Latn"
case bosnianBosniaHerzegovina = "bs_Latn_BA"
case bosnianCyrillic = "bs_Cyrl"
case bosnianCyrillicBosniaHerzegovina = "bs_Cyrl_BA"
case breton = "br"
case bretonFrance = "br_FR"
case bulgarian = "bg"
case bulgarianBulgaria = "bg_BG"
case burmese = "my"
case burmeseMyanmarBurma = "my_MM"
case catalan = "ca"
case catalanAndorra = "ca_AD"
case catalanFrance = "ca_FR"
case catalanItaly = "ca_IT"
case catalanSpain = "ca_ES"
case centralAtlasTamazight = "tzm_Latn"
case centralAtlasTamazightMorocco = "tzm_Latn_MA"
case centralKurdish = "ckb"
case centralKurdishIran = "ckb_IR"
case centralKurdishIraq = "ckb_IQ"
case cherokee = "chr"
case cherokeeUnitedStates = "chr_US"
case chiga = "cgg"
case chigaUganda = "cgg_UG"
case chinese = "zh"
case chineseChina = "zh_Hans_CN"
case chineseHongKongSarChina = "zh_Hant_HK"
case chineseMacauSarChina = "zh_Hant_MO"
case chineseSimplified = "zh_Hans"
case chineseSimplifiedHongKongSarChina = "zh_Hans_HK"
case chineseSimplifiedMacauSarChina = "zh_Hans_MO"
case chineseSingapore = "zh_Hans_SG"
case chineseTaiwan = "zh_Hant_TW"
case chineseTraditional = "zh_Hant"
case colognian = "ksh"
case colognianGermany = "ksh_DE"
case cornish = "kw"
case cornishUnitedKingdom = "kw_GB"
case croatian = "hr"
case croatianBosniaHerzegovina = "hr_BA"
case croatianCroatia = "hr_HR"
case czech = "cs"
case czechCzechRepublic = "cs_CZ"
case danish = "da"
case danishDenmark = "da_DK"
case danishGreenland = "da_GL"
case duala = "dua"
case dualaCameroon = "dua_CM"
case dutch = "nl"
case dutchAruba = "nl_AW"
case dutchBelgium = "nl_BE"
case dutchCaribbeanNetherlands = "nl_BQ"
case dutchCuraao = "nl_CW"
case dutchNetherlands = "nl_NL"
case dutchSintMaarten = "nl_SX"
case dutchSuriname = "nl_SR"
case dzongkha = "dz"
case dzongkhaBhutan = "dz_BT"
case embu = "ebu"
case embuKenya = "ebu_KE"
case english = "en"
case englishAlbania = "en_AL"
case englishAmericanSamoa = "en_AS"
case englishAndorra = "en_AD"
case englishAnguilla = "en_AI"
case englishAntiguaBarbuda = "en_AG"
case englishAustralia = "en_AU"
case englishAustria = "en_AT"
case englishBahamas = "en_BS"
case englishBarbados = "en_BB"
case englishBelgium = "en_BE"
case englishBelize = "en_BZ"
case englishBermuda = "en_BM"
case englishBosniaHerzegovina = "en_BA"
case englishBotswana = "en_BW"
case englishBritishIndianOceanTerritory = "en_IO"
case englishBritishVirginIslands = "en_VG"
case englishCameroon = "en_CM"
case englishCanada = "en_CA"
case englishCaymanIslands = "en_KY"
case englishChristmasIsland = "en_CX"
case englishCocosKeelingIslands = "en_CC"
case englishCookIslands = "en_CK"
case englishCroatia = "en_HR"
case englishCyprus = "en_CY"
case englishCzechRepublic = "en_CZ"
case englishDenmark = "en_DK"
case englishDiegoGarcia = "en_DG"
case englishDominica = "en_DM"
case englishEritrea = "en_ER"
case englishEstonia = "en_EE"
case englishEurope = "en_150"
case englishFalklandIslands = "en_FK"
case englishFiji = "en_FJ"
case englishFinland = "en_FI"
case englishFrance = "en_FR"
case englishGambia = "en_GM"
case englishGermany = "en_DE"
case englishGhana = "en_GH"
case englishGibraltar = "en_GI"
case englishGreece = "en_GR"
case englishGrenada = "en_GD"
case englishGuam = "en_GU"
case englishGuernsey = "en_GG"
case englishGuyana = "en_GY"
case englishHongKongSarChina = "en_HK"
case englishHungary = "en_HU"
case englishIceland = "en_IS"
case englishIndia = "en_IN"
case englishIreland = "en_IE"
case englishIsleOfMan = "en_IM"
case englishIsrael = "en_IL"
case englishItaly = "en_IT"
case englishJamaica = "en_JM"
case englishJersey = "en_JE"
case englishKenya = "en_KE"
case englishKiribati = "en_KI"
case englishLatvia = "en_LV"
case englishLesotho = "en_LS"
case englishLiberia = "en_LR"
case englishLithuania = "en_LT"
case englishLuxembourg = "en_LU"
case englishMacauSarChina = "en_MO"
case englishMadagascar = "en_MG"
case englishMalawi = "en_MW"
case englishMalaysia = "en_MY"
case englishMalta = "en_MT"
case englishMarshallIslands = "en_MH"
case englishMauritius = "en_MU"
case englishMicronesia = "en_FM"
case englishMontenegro = "en_ME"
case englishMontserrat = "en_MS"
case englishNamibia = "en_NA"
case englishNauru = "en_NR"
case englishNetherlands = "en_NL"
case englishNewZealand = "en_NZ"
case englishNigeria = "en_NG"
case englishNiue = "en_NU"
case englishNorfolkIsland = "en_NF"
case englishNorthernMarianaIslands = "en_MP"
case englishNorway = "en_NO"
case englishPakistan = "en_PK"
case englishPalau = "en_PW"
case englishPapuaNewGuinea = "en_PG"
case englishPhilippines = "en_PH"
case englishPitcairnIslands = "en_PN"
case englishPoland = "en_PL"
case englishPortugal = "en_PT"
case englishPuertoRico = "en_PR"
case englishRomania = "en_RO"
case englishRussia = "en_RU"
case englishRwanda = "en_RW"
case englishSamoa = "en_WS"
case englishSeychelles = "en_SC"
case englishSierraLeone = "en_SL"
case englishSingapore = "en_SG"
case englishSintMaarten = "en_SX"
case englishSlovakia = "en_SK"
case englishSlovenia = "en_SI"
case englishSolomonIslands = "en_SB"
case englishSouthAfrica = "en_ZA"
case englishSouthSudan = "en_SS"
case englishSpain = "en_ES"
case englishStHelena = "en_SH"
case englishStKittsNevis = "en_KN"
case englishStLucia = "en_LC"
case englishStVincentGrenadines = "en_VC"
case englishSudan = "en_SD"
case englishSwaziland = "en_SZ"
case englishSweden = "en_SE"
case englishSwitzerland = "en_CH"
case englishTanzania = "en_TZ"
case englishTokelau = "en_TK"
case englishTonga = "en_TO"
case englishTrinidadTobago = "en_TT"
case englishTurkey = "en_TR"
case englishTurksCaicosIslands = "en_TC"
case englishTuvalu = "en_TV"
case englishUSOutlyingIslands = "en_UM"
case englishUSVirginIslands = "en_VI"
case englishUganda = "en_UG"
case englishUnitedKingdom = "en_GB"
case englishUnitedStates = "en_US"
case englishUnitedStatesComputer = "en_US_POSIX"
case englishVanuatu = "en_VU"
case englishWorld = "en_001"
case englishZambia = "en_ZM"
case englishZimbabwe = "en_ZW"
case esperanto = "eo"
case estonian = "et"
case estonianEstonia = "et_EE"
case ewe = "ee"
case eweGhana = "ee_GH"
case eweTogo = "ee_TG"
case ewondo = "ewo"
case ewondoCameroon = "ewo_CM"
case faroese = "fo"
case faroeseFaroeIslands = "fo_FO"
case filipino = "fil"
case filipinoPhilippines = "fil_PH"
case finnish = "fi"
case finnishFinland = "fi_FI"
case french = "fr"
case frenchAlgeria = "fr_DZ"
case frenchBelgium = "fr_BE"
case frenchBenin = "fr_BJ"
case frenchBurkinaFaso = "fr_BF"
case frenchBurundi = "fr_BI"
case frenchCameroon = "fr_CM"
case frenchCanada = "fr_CA"
case frenchCentralAfricanRepublic = "fr_CF"
case frenchChad = "fr_TD"
case frenchComoros = "fr_KM"
case frenchCongoBrazzaville = "fr_CG"
case frenchCongoKinshasa = "fr_CD"
case frenchCteDivoire = "fr_CI"
case frenchDjibouti = "fr_DJ"
case frenchEquatorialGuinea = "fr_GQ"
case frenchFrance = "fr_FR"
case frenchFrenchGuiana = "fr_GF"
case frenchFrenchPolynesia = "fr_PF"
case frenchGabon = "fr_GA"
case frenchGuadeloupe = "fr_GP"
case frenchGuinea = "fr_GN"
case frenchHaiti = "fr_HT"
case frenchLuxembourg = "fr_LU"
case frenchMadagascar = "fr_MG"
case frenchMali = "fr_ML"
case frenchMartinique = "fr_MQ"
case frenchMauritania = "fr_MR"
case frenchMauritius = "fr_MU"
case frenchMayotte = "fr_YT"
case frenchMonaco = "fr_MC"
case frenchMorocco = "fr_MA"
case frenchNewCaledonia = "fr_NC"
case frenchNiger = "fr_NE"
case frenchRunion = "fr_RE"
case frenchRwanda = "fr_RW"
case frenchSenegal = "fr_SN"
case frenchSeychelles = "fr_SC"
case frenchStBarthlemy = "fr_BL"
case frenchStMartin = "fr_MF"
case frenchStPierreMiquelon = "fr_PM"
case frenchSwitzerland = "fr_CH"
case frenchSyria = "fr_SY"
case frenchTogo = "fr_TG"
case frenchTunisia = "fr_TN"
case frenchVanuatu = "fr_VU"
case frenchWallisFutuna = "fr_WF"
case friulian = "fur"
case friulianItaly = "fur_IT"
case fulah = "ff"
case fulahCameroon = "ff_CM"
case fulahGuinea = "ff_GN"
case fulahMauritania = "ff_MR"
case fulahSenegal = "ff_SN"
case galician = "gl"
case galicianSpain = "gl_ES"
case ganda = "lg"
case gandaUganda = "lg_UG"
case georgian = "ka"
case georgianGeorgia = "ka_GE"
case german = "de"
case germanAustria = "de_AT"
case germanBelgium = "de_BE"
case germanGermany = "de_DE"
case germanLiechtenstein = "de_LI"
case germanLuxembourg = "de_LU"
case germanSwitzerland = "de_CH"
case greek = "el"
case greekCyprus = "el_CY"
case greekGreece = "el_GR"
case gujarati = "gu"
case gujaratiIndia = "gu_IN"
case gusii = "guz"
case gusiiKenya = "guz_KE"
case hausa = "ha_Latn"
case hausaGhana = "ha_Latn_GH"
case hausaNiger = "ha_Latn_NE"
case hausaNigeria = "ha_Latn_NG"
case hawaiian = "haw"
case hawaiianUnitedStates = "haw_US"
case hebrew = "he"
case hebrewIsrael = "he_IL"
case hindi = "hi"
case hindiIndia = "hi_IN"
case hungarian = "hu"
case hungarianHungary = "hu_HU"
case icelandic = "is"
case icelandicIceland = "is_IS"
case igbo = "ig"
case igboNigeria = "ig_NG"
case inariSami = "smn"
case inariSamiFinland = "smn_FI"
case indonesian = "id"
case indonesianIndonesia = "id_ID"
case inuktitut = "iu"
case inuktitutUnifiedCanadianAboriginalSyllabics = "iu_Cans"
case inuktitutUnifiedCanadianAboriginalSyllabicsCanada = "iu_Cans_CA"
case irish = "ga"
case irishIreland = "ga_IE"
case italian = "it"
case italianItaly = "it_IT"
case italianSanMarino = "it_SM"
case italianSwitzerland = "it_CH"
case japanese = "ja"
case japaneseJapan = "ja_JP"
case jolaFonyi = "dyo"
case jolaFonyiSenegal = "dyo_SN"
case kabuverdianu = "kea"
case kabuverdianuCapeVerde = "kea_CV"
case kabyle = "kab"
case kabyleAlgeria = "kab_DZ"
case kako = "kkj"
case kakoCameroon = "kkj_CM"
case kalaallisut = "kl"
case kalaallisutGreenland = "kl_GL"
case kalenjin = "kln"
case kalenjinKenya = "kln_KE"
case kamba = "kam"
case kambaKenya = "kam_KE"
case kannada = "kn"
case kannadaIndia = "kn_IN"
case kashmiri = "ks"
case kashmiriArabic = "ks_Arab"
case kashmiriArabicIndia = "ks_Arab_IN"
case kazakh = "kk_Cyrl"
case kazakhKazakhstan = "kk_Cyrl_KZ"
case khmer = "km"
case khmerCambodia = "km_KH"
case kikuyu = "ki"
case kikuyuKenya = "ki_KE"
case kinyarwanda = "rw"
case kinyarwandaRwanda = "rw_RW"
case konkani = "kok"
case konkaniIndia = "kok_IN"
case korean = "ko"
case koreanNorthKorea = "ko_KP"
case koreanSouthKorea = "ko_KR"
case koyraChiini = "khq"
case koyraChiiniMali = "khq_ML"
case koyraboroSenni = "ses"
case koyraboroSenniMali = "ses_ML"
case kwasio = "nmg"
case kwasioCameroon = "nmg_CM"
case kyrgyz = "ky_Cyrl"
case kyrgyzKyrgyzstan = "ky_Cyrl_KG"
case lakota = "lkt"
case lakotaUnitedStates = "lkt_US"
case langi = "lag"
case langiTanzania = "lag_TZ"
case lao = "lo"
case laoLaos = "lo_LA"
case latvian = "lv"
case latvianLatvia = "lv_LV"
case lingala = "ln"
case lingalaAngola = "ln_AO"
case lingalaCentralAfricanRepublic = "ln_CF"
case lingalaCongoBrazzaville = "ln_CG"
case lingalaCongoKinshasa = "ln_CD"
case lithuanian = "lt"
case lithuanianLithuania = "lt_LT"
case lowerSorbian = "dsb"
case lowerSorbianGermany = "dsb_DE"
case lubaKatanga = "lu"
case lubaKatangaCongoKinshasa = "lu_CD"
case luo = "luo"
case luoKenya = "luo_KE"
case luxembourgish = "lb"
case luxembourgishLuxembourg = "lb_LU"
case luyia = "luy"
case luyiaKenya = "luy_KE"
case macedonian = "mk"
case macedonianMacedonia = "mk_MK"
case machame = "jmc"
case machameTanzania = "jmc_TZ"
case makhuwaMeetto = "mgh"
case makhuwaMeettoMozambique = "mgh_MZ"
case makonde = "kde"
case makondeTanzania = "kde_TZ"
case malagasy = "mg"
case malagasyMadagascar = "mg_MG"
case malay = "ms_Latn"
case malayArabic = "ms_Arab"
case malayArabicBrunei = "ms_Arab_BN"
case malayArabicMalaysia = "ms_Arab_MY"
case malayBrunei = "ms_Latn_BN"
case malayMalaysia = "ms_Latn_MY"
case malaySingapore = "ms_Latn_SG"
case malayalam = "ml"
case malayalamIndia = "ml_IN"
case maltese = "mt"
case malteseMalta = "mt_MT"
case manx = "gv"
case manxIsleOfMan = "gv_IM"
case marathi = "mr"
case marathiIndia = "mr_IN"
case masai = "mas"
case masaiKenya = "mas_KE"
case masaiTanzania = "mas_TZ"
case meru = "mer"
case meruKenya = "mer_KE"
case meta = "mgo"
case metaCameroon = "mgo_CM"
case mongolian = "mn_Cyrl"
case mongolianMongolia = "mn_Cyrl_MN"
case morisyen = "mfe"
case morisyenMauritius = "mfe_MU"
case mundang = "mua"
case mundangCameroon = "mua_CM"
case nama = "naq"
case namaNamibia = "naq_NA"
case nepali = "ne"
case nepaliIndia = "ne_IN"
case nepaliNepal = "ne_NP"
case ngiemboon = "nnh"
case ngiemboonCameroon = "nnh_CM"
case ngomba = "jgo"
case ngombaCameroon = "jgo_CM"
case northNdebele = "nd"
case northNdebeleZimbabwe = "nd_ZW"
case northernSami = "se"
case northernSamiFinland = "se_FI"
case northernSamiNorway = "se_NO"
case northernSamiSweden = "se_SE"
case norwegianBokml = "nb"
case norwegianBokmlNorway = "nb_NO"
case norwegianBokmlSvalbardJanMayen = "nb_SJ"
case norwegianNynorsk = "nn"
case norwegianNynorskNorway = "nn_NO"
case nuer = "nus"
case nuerSudan = "nus_SD"
case nyankole = "nyn"
case nyankoleUganda = "nyn_UG"
case oriya = "or"
case oriyaIndia = "or_IN"
case oromo = "om"
case oromoEthiopia = "om_ET"
case oromoKenya = "om_KE"
case ossetic = "os"
case osseticGeorgia = "os_GE"
case osseticRussia = "os_RU"
case pashto = "ps"
case pashtoAfghanistan = "ps_AF"
case persian = "fa"
case persianAfghanistan = "fa_AF"
case persianIran = "fa_IR"
case polish = "pl"
case polishPoland = "pl_PL"
case portuguese = "pt"
case portugueseAngola = "pt_AO"
case portugueseBrazil = "pt_BR"
case portugueseCapeVerde = "pt_CV"
case portugueseGuineaBissau = "pt_GW"
case portugueseMacauSarChina = "pt_MO"
case portugueseMozambique = "pt_MZ"
case portuguesePortugal = "pt_PT"
case portugueseSoTomPrncipe = "pt_ST"
case portugueseTimorLeste = "pt_TL"
case punjabi = "pa_Guru"
case punjabiArabic = "pa_Arab"
case punjabiArabicPakistan = "pa_Arab_PK"
case punjabiIndia = "pa_Guru_IN"
case quechua = "qu"
case quechuaBolivia = "qu_BO"
case quechuaEcuador = "qu_EC"
case quechuaPeru = "qu_PE"
case romanian = "ro"
case romanianMoldova = "ro_MD"
case romanianRomania = "ro_RO"
case romansh = "rm"
case romanshSwitzerland = "rm_CH"
case rombo = "rof"
case romboTanzania = "rof_TZ"
case rundi = "rn"
case rundiBurundi = "rn_BI"
case russian = "ru"
case russianBelarus = "ru_BY"
case russianKazakhstan = "ru_KZ"
case russianKyrgyzstan = "ru_KG"
case russianMoldova = "ru_MD"
case russianRussia = "ru_RU"
case russianUkraine = "ru_UA"
case rwa = "rwk"
case rwaTanzania = "rwk_TZ"
case sakha = "sah"
case sakhaRussia = "sah_RU"
case samburu = "saq"
case samburuKenya = "saq_KE"
case sango = "sg"
case sangoCentralAfricanRepublic = "sg_CF"
case sangu = "sbp"
case sanguTanzania = "sbp_TZ"
case scottishGaelic = "gd"
case scottishGaelicUnitedKingdom = "gd_GB"
case sena = "seh"
case senaMozambique = "seh_MZ"
case serbian = "sr_Cyrl"
case serbianBosniaHerzegovina = "sr_Cyrl_BA"
case serbianKosovo = "sr_Cyrl_XK"
case serbianLatin = "sr_Latn"
case serbianLatinBosniaHerzegovina = "sr_Latn_BA"
case serbianLatinKosovo = "sr_Latn_XK"
case serbianLatinMontenegro = "sr_Latn_ME"
case serbianLatinSerbia = "sr_Latn_RS"
case serbianMontenegro = "sr_Cyrl_ME"
case serbianSerbia = "sr_Cyrl_RS"
case shambala = "ksb"
case shambalaTanzania = "ksb_TZ"
case shona = "sn"
case shonaZimbabwe = "sn_ZW"
case sichuanYi = "ii"
case sichuanYiChina = "ii_CN"
case sinhala = "si"
case sinhalaSriLanka = "si_LK"
case slovak = "sk"
case slovakSlovakia = "sk_SK"
case slovenian = "sl"
case slovenianSlovenia = "sl_SI"
case soga = "xog"
case sogaUganda = "xog_UG"
case somali = "so"
case somaliDjibouti = "so_DJ"
case somaliEthiopia = "so_ET"
case somaliKenya = "so_KE"
case somaliSomalia = "so_SO"
case spanish = "es"
case spanishArgentina = "es_AR"
case spanishBolivia = "es_BO"
case spanishCanaryIslands = "es_IC"
case spanishCeutaMelilla = "es_EA"
case spanishChile = "es_CL"
case spanishColombia = "es_CO"
case spanishCostaRica = "es_CR"
case spanishCuba = "es_CU"
case spanishDominicanRepublic = "es_DO"
case spanishEcuador = "es_EC"
case spanishElSalvador = "es_SV"
case spanishEquatorialGuinea = "es_GQ"
case spanishGuatemala = "es_GT"
case spanishHonduras = "es_HN"
case spanishLatinAmerica = "es_419"
case spanishMexico = "es_MX"
case spanishNicaragua = "es_NI"
case spanishPanama = "es_PA"
case spanishParaguay = "es_PY"
case spanishPeru = "es_PE"
case spanishPhilippines = "es_PH"
case spanishPuertoRico = "es_PR"
case spanishSpain = "es_ES"
case spanishUnitedStates = "es_US"
case spanishUruguay = "es_UY"
case spanishVenezuela = "es_VE"
case standardMoroccanTamazight = "zgh"
case standardMoroccanTamazightMorocco = "zgh_MA"
case swahili = "sw"
case swahiliCongoKinshasa = "sw_CD"
case swahiliKenya = "sw_KE"
case swahiliTanzania = "sw_TZ"
case swahiliUganda = "sw_UG"
case swedish = "sv"
case swedishlandIslands = "sv_AX"
case swedishFinland = "sv_FI"
case swedishSweden = "sv_SE"
case swissGerman = "gsw"
case swissGermanFrance = "gsw_FR"
case swissGermanLiechtenstein = "gsw_LI"
case swissGermanSwitzerland = "gsw_CH"
case tachelhit = "shi_Latn"
case tachelhitMorocco = "shi_Latn_MA"
case tachelhitTifinagh = "shi_Tfng"
case tachelhitTifinaghMorocco = "shi_Tfng_MA"
case taita = "dav"
case taitaKenya = "dav_KE"
case tajik = "tg_Cyrl"
case tajikTajikistan = "tg_Cyrl_TJ"
case tamil = "ta"
case tamilIndia = "ta_IN"
case tamilMalaysia = "ta_MY"
case tamilSingapore = "ta_SG"
case tamilSriLanka = "ta_LK"
case tasawaq = "twq"
case tasawaqNiger = "twq_NE"
case telugu = "te"
case teluguIndia = "te_IN"
case teso = "teo"
case tesoKenya = "teo_KE"
case tesoUganda = "teo_UG"
case thai = "th"
case thaiThailand = "th_TH"
case tibetan = "bo"
case tibetanChina = "bo_CN"
case tibetanIndia = "bo_IN"
case tigrinya = "ti"
case tigrinyaEritrea = "ti_ER"
case tigrinyaEthiopia = "ti_ET"
case tongan = "to"
case tonganTonga = "to_TO"
case turkish = "tr"
case turkishCyprus = "tr_CY"
case turkishTurkey = "tr_TR"
case turkmen = "tk_Latn"
case turkmenTurkmenistan = "tk_Latn_TM"
case ukrainian = "uk"
case ukrainianUkraine = "uk_UA"
case upperSorbian = "hsb"
case upperSorbianGermany = "hsb_DE"
case urdu = "ur"
case urduIndia = "ur_IN"
case urduPakistan = "ur_PK"
case uyghur = "ug"
case uyghurArabic = "ug_Arab"
case uyghurArabicChina = "ug_Arab_CN"
case uzbek = "uz_Cyrl"
case uzbekArabic = "uz_Arab"
case uzbekArabicAfghanistan = "uz_Arab_AF"
case uzbekLatin = "uz_Latn"
case uzbekLatinUzbekistan = "uz_Latn_UZ"
case uzbekUzbekistan = "uz_Cyrl_UZ"
case vai = "vai_Vaii"
case vaiLatin = "vai_Latn"
case vaiLatinLiberia = "vai_Latn_LR"
case vaiLiberia = "vai_Vaii_LR"
case vietnamese = "vi"
case vietnameseVietnam = "vi_VN"
case vunjo = "vun"
case vunjoTanzania = "vun_TZ"
case walser = "wae"
case walserSwitzerland = "wae_CH"
case welsh = "cy"
case welshUnitedKingdom = "cy_GB"
case westernFrisian = "fy"
case westernFrisianNetherlands = "fy_NL"
case yangben = "yav"
case yangbenCameroon = "yav_CM"
case yiddish = "yi"
case yiddishWorld = "yi_001"
case yoruba = "yo"
case yorubaBenin = "yo_BJ"
case yorubaNigeria = "yo_NG"
case zarma = "dje"
case zarmaNiger = "dje_NE"
case zulu = "zu"
case zuluSouthAfrica = "zu_ZA"
/// Return a valid `Locale` instance from currency locale enum
public var locale: Locale {
switch self {
case .current: return Locale.current
case .autoUpdating: return Locale.autoupdatingCurrent
default: return Locale(identifier: rawValue)
}
}
}
@@ -0,0 +1,18 @@
//
// NumberFormatter.swift
// CurrencyText
//
// Created by Felipe Lefèvre Marino on 12/27/18.
//
import Foundation
public extension NumberFormatter {
func string(from doubleValue: Double?) -> String? {
if let doubleValue = doubleValue {
return string(from: NSNumber(value: doubleValue))
}
return nil
}
}
@@ -0,0 +1,69 @@
//
// String.swift
// CurrencyText
//
// Created by Felipe Lefèvre Marino on 4/3/18.
// Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
//
import Foundation
public protocol CurrencyString {
var representsZero: Bool { get }
var hasNumbers: Bool { get }
var lastNumberOffsetFromEnd: Int? { get }
func numeralFormat() -> String
mutating func updateDecimalSeparator(decimalDigits: Int)
}
//Currency String Extension
extension String: CurrencyString {
// MARK: Properties
/// Informs with the string represents the value of zero
public var representsZero: Bool {
return numeralFormat().replacingOccurrences(of: "0", with: "").count == 0
}
/// Returns if the string does have any character that represents numbers
public var hasNumbers: Bool {
return numeralFormat().count > 0
}
/// The offset from end index to the index _right after_ the last number in the String.
/// e.g. For the String "123some", the last number position is 4, because from the _end index_ to the index of _3_
/// there is an offset of 4, "e, m, o and s".
public var lastNumberOffsetFromEnd: Int? {
guard let indexOfLastNumber = lastIndex(where: { $0.isNumber }) else { return nil }
let indexAfterLastNumber = index(after: indexOfLastNumber)
return distance(from: endIndex, to: indexAfterLastNumber)
}
// MARK: Functions
/// Updates a currency string decimal separator position based on
/// the amount of decimal digits desired
///
/// - Parameter decimalDigits: The amount of decimal digits of the currency formatted string
public mutating func updateDecimalSeparator(decimalDigits: Int) {
guard decimalDigits != 0 && count >= decimalDigits else { return }
let decimalsRange = index(endIndex, offsetBy: -decimalDigits)..<endIndex
let decimalChars = self[decimalsRange]
replaceSubrange(decimalsRange, with: "." + decimalChars)
}
/// The numeral format of a string - remove all non numerical ocurrences
///
/// - Returns: itself without the non numerical characters ocurrences
public func numeralFormat() -> String {
return replacingOccurrences(of:"[^0-9]", with: "", options: .regularExpression)
}
}
// MARK: - Static constants
extension String {
public static let negativeSymbol = "-"
}
@@ -0,0 +1,459 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import AccountContext
private final class TipEditInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegate {
private var theme: PresentationTheme
private let backgroundNode: ASImageNode
private let textInputNode: EditableTextNode
private let placeholderNode: ASTextNode
private let clearButton: HighlightableButtonNode
var updateHeight: (() -> Void)?
var complete: (() -> Void)?
var textChanged: ((String) -> Void)?
private let backgroundInsets = UIEdgeInsets(top: 8.0, left: 16.0, bottom: 15.0, right: 16.0)
private let inputInsets = UIEdgeInsets(top: 5.0, left: 12.0, bottom: 5.0, right: 12.0)
var text: String {
get {
return self.textInputNode.attributedText?.string ?? ""
}
set {
self.textInputNode.attributedText = NSAttributedString(string: newValue, font: Font.regular(17.0), textColor: self.theme.actionSheet.inputTextColor)
self.placeholderNode.isHidden = !newValue.isEmpty
self.clearButton.isHidden = newValue.isEmpty
}
}
var placeholder: String = "" {
didSet {
self.placeholderNode.attributedText = NSAttributedString(string: self.placeholder, font: Font.regular(17.0), textColor: self.theme.actionSheet.inputPlaceholderColor)
}
}
init(theme: PresentationTheme, placeholder: String) {
self.theme = theme
self.backgroundNode = ASImageNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.displaysAsynchronously = false
self.backgroundNode.displayWithoutProcessing = true
self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 12.0, color: theme.actionSheet.inputHollowBackgroundColor, strokeColor: theme.actionSheet.inputBorderColor, strokeWidth: 1.0)
self.textInputNode = EditableTextNode()
self.textInputNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(17.0), NSAttributedString.Key.foregroundColor.rawValue: theme.actionSheet.inputTextColor]
self.textInputNode.clipsToBounds = true
self.textInputNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0)
self.textInputNode.textContainerInset = UIEdgeInsets(top: self.inputInsets.top, left: 0.0, bottom: self.inputInsets.bottom, right: 0.0)
self.textInputNode.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance
self.textInputNode.keyboardType = .default
self.textInputNode.autocapitalizationType = .sentences
self.textInputNode.returnKeyType = .done
self.textInputNode.autocorrectionType = .default
self.textInputNode.tintColor = theme.actionSheet.controlAccentColor
self.placeholderNode = ASTextNode()
self.placeholderNode.isUserInteractionEnabled = false
self.placeholderNode.displaysAsynchronously = false
self.placeholderNode.attributedText = NSAttributedString(string: placeholder, font: Font.regular(17.0), textColor: self.theme.actionSheet.inputPlaceholderColor)
self.clearButton = HighlightableButtonNode()
self.clearButton.imageNode.displaysAsynchronously = false
self.clearButton.imageNode.displayWithoutProcessing = true
self.clearButton.displaysAsynchronously = false
self.clearButton.setImage(generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: theme.actionSheet.inputClearButtonColor), for: [])
self.clearButton.isHidden = true
super.init()
self.textInputNode.delegate = self
self.addSubnode(self.backgroundNode)
self.addSubnode(self.textInputNode)
self.addSubnode(self.placeholderNode)
self.addSubnode(self.clearButton)
self.clearButton.addTarget(self, action: #selector(self.clearPressed), forControlEvents: .touchUpInside)
}
func updateTheme(_ theme: PresentationTheme) {
self.theme = theme
self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 12.0, color: self.theme.actionSheet.inputHollowBackgroundColor, strokeColor: self.theme.actionSheet.inputBorderColor, strokeWidth: 1.0)
self.textInputNode.keyboardAppearance = self.theme.rootController.keyboardColor.keyboardAppearance
self.placeholderNode.attributedText = NSAttributedString(string: self.placeholderNode.attributedText?.string ?? "", font: Font.regular(17.0), textColor: self.theme.actionSheet.inputPlaceholderColor)
self.textInputNode.tintColor = self.theme.actionSheet.controlAccentColor
self.clearButton.setImage(generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: theme.actionSheet.inputClearButtonColor), for: [])
}
func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
let backgroundInsets = self.backgroundInsets
let inputInsets = self.inputInsets
let textFieldHeight = self.calculateTextFieldMetrics(width: width)
let panelHeight = textFieldHeight + backgroundInsets.top + backgroundInsets.bottom
let backgroundFrame = CGRect(origin: CGPoint(x: backgroundInsets.left, y: backgroundInsets.top), size: CGSize(width: width - backgroundInsets.left - backgroundInsets.right, height: panelHeight - backgroundInsets.top - backgroundInsets.bottom))
transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
let placeholderSize = self.placeholderNode.measure(backgroundFrame.size)
transition.updateFrame(node: self.placeholderNode, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + inputInsets.left, y: backgroundFrame.minY + floor((backgroundFrame.size.height - placeholderSize.height) / 2.0)), size: placeholderSize))
transition.updateFrame(node: self.textInputNode, frame: CGRect(origin: CGPoint(x: backgroundFrame.minX + inputInsets.left, y: backgroundFrame.minY), size: CGSize(width: backgroundFrame.size.width - inputInsets.left - inputInsets.right - 20.0, height: backgroundFrame.size.height)))
if let image = self.clearButton.image(for: []) {
transition.updateFrame(node: self.clearButton, frame: CGRect(origin: CGPoint(x: backgroundFrame.maxX - 8.0 - image.size.width, y: backgroundFrame.minY + floor((backgroundFrame.size.height - image.size.height) / 2.0)), size: image.size))
}
return panelHeight
}
func activateInput() {
self.textInputNode.becomeFirstResponder()
}
func deactivateInput() {
self.textInputNode.resignFirstResponder()
}
@objc func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) {
self.updateTextNodeText(animated: true)
self.textChanged?(editableTextNode.textView.text)
self.placeholderNode.isHidden = !(editableTextNode.textView.text ?? "").isEmpty
self.clearButton.isHidden = !self.placeholderNode.isHidden
}
func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
let updatedText = (editableTextNode.textView.text as NSString).replacingCharacters(in: range, with: text)
if updatedText.count > 40 {
self.textInputNode.layer.addShakeAnimation()
return false
}
if text == "\n" {
self.complete?()
return false
}
return true
}
private func calculateTextFieldMetrics(width: CGFloat) -> CGFloat {
let backgroundInsets = self.backgroundInsets
let inputInsets = self.inputInsets
let unboundTextFieldHeight = max(33.0, ceil(self.textInputNode.measure(CGSize(width: width - backgroundInsets.left - backgroundInsets.right - inputInsets.left - inputInsets.right - 20.0, height: CGFloat.greatestFiniteMagnitude)).height))
return min(61.0, max(33.0, unboundTextFieldHeight))
}
private func updateTextNodeText(animated: Bool) {
let backgroundInsets = self.backgroundInsets
let textFieldHeight = self.calculateTextFieldMetrics(width: self.bounds.size.width)
let panelHeight = textFieldHeight + backgroundInsets.top + backgroundInsets.bottom
if !self.bounds.size.height.isEqual(to: panelHeight) {
self.updateHeight?()
}
}
@objc func clearPressed() {
self.placeholderNode.isHidden = false
self.clearButton.isHidden = true
self.textInputNode.attributedText = nil
self.deactivateInput()
self.updateHeight?()
}
}
private final class TipEditAlertContentNode: AlertContentNode {
private let strings: PresentationStrings
private let title: String
private let text: String
private let titleNode: ASTextNode
private let textNode: ASTextNode
let inputFieldNode: TipEditInputFieldNode
private let actionNodesSeparator: ASDisplayNode
private let actionNodes: [TextAlertContentActionNode]
private let actionVerticalSeparators: [ASDisplayNode]
private let disposable = MetaDisposable()
private var validLayout: CGSize?
private let hapticFeedback = HapticFeedback()
var complete: (() -> Void)? {
didSet {
self.inputFieldNode.complete = self.complete
}
}
override var dismissOnOutsideTap: Bool {
return self.isUserInteractionEnabled
}
init(theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, actions: [TextAlertAction], title: String, text: String, placeholder: String, value: String?) {
self.strings = strings
self.title = title
self.text = text
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 2
self.textNode = ASTextNode()
self.textNode.maximumNumberOfLines = 8
self.inputFieldNode = TipEditInputFieldNode(theme: ptheme, placeholder: placeholder)
self.inputFieldNode.text = value ?? ""
self.actionNodesSeparator = ASDisplayNode()
self.actionNodesSeparator.isLayerBacked = true
self.actionNodes = actions.map { action -> TextAlertContentActionNode in
return TextAlertContentActionNode(theme: theme, action: action)
}
var actionVerticalSeparators: [ASDisplayNode] = []
if actions.count > 1 {
for _ in 0 ..< actions.count - 1 {
let separatorNode = ASDisplayNode()
separatorNode.isLayerBacked = true
actionVerticalSeparators.append(separatorNode)
}
}
self.actionVerticalSeparators = actionVerticalSeparators
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.inputFieldNode)
self.addSubnode(self.actionNodesSeparator)
for actionNode in self.actionNodes {
self.addSubnode(actionNode)
}
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}
self.inputFieldNode.updateHeight = { [weak self] in
if let strongSelf = self {
if let _ = strongSelf.validLayout {
strongSelf.requestLayout?(.animated(duration: 0.15, curve: .spring))
}
}
}
self.updateTheme(theme)
}
deinit {
self.disposable.dispose()
}
var value: String {
return self.inputFieldNode.text
}
override func updateTheme(_ theme: AlertControllerTheme) {
self.titleNode.attributedText = NSAttributedString(string: self.title, font: Font.bold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.textNode.attributedText = NSAttributedString(string: self.text, font: Font.regular(13.0), textColor: theme.primaryColor, paragraphAlignment: .center)
self.actionNodesSeparator.backgroundColor = theme.separatorColor
for actionNode in self.actionNodes {
actionNode.updateTheme(theme)
}
for separatorNode in self.actionVerticalSeparators {
separatorNode.backgroundColor = theme.separatorColor
}
if let size = self.validLayout {
_ = self.updateLayout(size: size, transition: .immediate)
}
}
override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
var size = size
size.width = min(size.width, 270.0)
let measureSize = CGSize(width: size.width - 16.0 * 2.0, height: CGFloat.greatestFiniteMagnitude)
let hadValidLayout = self.validLayout != nil
self.validLayout = size
var origin: CGPoint = CGPoint(x: 0.0, y: 20.0)
let spacing: CGFloat = 5.0
let titleSize = self.titleNode.measure(measureSize)
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: origin.y), size: titleSize))
origin.y += titleSize.height + 4.0
let textSize = self.textNode.measure(measureSize)
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize))
origin.y += textSize.height + 6.0 + spacing
let actionButtonHeight: CGFloat = 44.0
var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
var effectiveActionLayout = TextAlertContentActionLayout.horizontal
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}
let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 9.0, right: 18.0)
var contentWidth = max(titleSize.width, minActionsWidth)
contentWidth = max(contentWidth, 234.0)
var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
}
let resultWidth = contentWidth + insets.left + insets.right
let inputFieldWidth = resultWidth
let inputFieldHeight = self.inputFieldNode.updateLayout(width: inputFieldWidth, transition: transition)
let inputHeight = inputFieldHeight
transition.updateFrame(node: self.inputFieldNode, frame: CGRect(x: 0.0, y: origin.y, width: resultWidth, height: inputFieldHeight))
transition.updateAlpha(node: self.inputFieldNode, alpha: inputHeight > 0.0 ? 1.0 : 0.0)
let resultSize = CGSize(width: resultWidth, height: titleSize.height + textSize.height + spacing + inputHeight + actionsHeight + insets.top + insets.bottom)
transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
if separatorIndex >= 0 {
let separatorNode = self.actionVerticalSeparators[separatorIndex]
switch effectiveActionLayout {
case .horizontal:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel)))
case .vertical:
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)))
}
}
separatorIndex += 1
let currentActionWidth: CGFloat
switch effectiveActionLayout {
case .horizontal:
if nodeIndex == self.actionNodes.count - 1 {
currentActionWidth = resultSize.width - actionOffset
} else {
currentActionWidth = actionWidth
}
case .vertical:
currentActionWidth = resultSize.width
}
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
}
transition.updateFrame(node: actionNode, frame: actionNodeFrame)
nodeIndex += 1
}
if !hadValidLayout {
self.inputFieldNode.activateInput()
}
return resultSize
}
func animateError() {
self.inputFieldNode.layer.addShakeAnimation()
self.hapticFeedback.error()
}
}
func tipEditController(sharedContext: SharedAccountContext, account: Account, forceTheme: PresentationTheme?, title: String, text: String, placeholder: String, doneButtonTitle: String? = nil, value: String?, apply: @escaping (String?) -> Void) -> AlertController {
var presentationData = sharedContext.currentPresentationData.with { $0 }
if let forceTheme = forceTheme {
presentationData = presentationData.withUpdated(theme: forceTheme)
}
var dismissImpl: ((Bool) -> Void)?
var applyImpl: (() -> Void)?
let actions: [TextAlertAction] = [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {
dismissImpl?(true)
}), TextAlertAction(type: .defaultAction, title: doneButtonTitle ?? presentationData.strings.Common_Done, action: {
applyImpl?()
})]
let contentNode = TipEditAlertContentNode(theme: AlertControllerTheme(presentationData: presentationData), ptheme: presentationData.theme, strings: presentationData.strings, actions: actions, title: title, text: text, placeholder: placeholder, value: value)
contentNode.complete = {
applyImpl?()
}
applyImpl = { [weak contentNode] in
guard let contentNode = contentNode else {
return
}
dismissImpl?(true)
let previousValue = value ?? ""
let newValue = contentNode.value.trimmingCharacters(in: .whitespacesAndNewlines)
apply(previousValue != newValue || value == nil ? newValue : nil)
}
let controller = AlertController(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode)
let presentationDataDisposable = sharedContext.presentationData.start(next: { [weak controller, weak contentNode] presentationData in
var presentationData = presentationData
if let forceTheme = forceTheme {
presentationData = presentationData.withUpdated(theme: forceTheme)
}
controller?.theme = AlertControllerTheme(presentationData: presentationData)
contentNode?.inputFieldNode.updateTheme(presentationData.theme)
})
controller.dismissed = { _ in
presentationDataDisposable.dispose()
}
dismissImpl = { [weak controller] animated in
contentNode.inputFieldNode.deactivateInput()
if animated {
controller?.dismissAnimated()
} else {
controller?.dismiss()
}
}
return controller
}
@@ -0,0 +1,188 @@
//
// CurrencyUITextFieldDelegate.swift
// CurrencyText
//
// Created by Felipe Lefèvre Marino on 12/26/18.
// Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
//
import UIKit
/// Custom text field delegate, that formats user inputs based on a given currency formatter.
public class CurrencyUITextFieldDelegate: NSObject {
public var formatter: (CurrencyFormatting & CurrencyAdjusting)!
public var textUpdated: (() -> Void)?
/// Text field clears its text when value value is equal to zero.
public var clearsWhenValueIsZero: Bool = false
/// A delegate object to receive and potentially handle `UITextFieldDelegate events` that are sent to `CurrencyUITextFieldDelegate`.
///
/// Note: Make sure the implementation of this object does not wrongly interfere with currency formatting.
///
/// By returning `false` on`textField(textField:shouldChangeCharactersIn:replacementString:)` no currency formatting is done.
public var passthroughDelegate: UITextFieldDelegate? {
get { return _passthroughDelegate }
set {
guard newValue !== self else { return }
_passthroughDelegate = newValue
}
}
weak private(set) var _passthroughDelegate: UITextFieldDelegate?
public init(formatter: CurrencyFormatter) {
self.formatter = formatter
}
}
// MARK: - UITextFieldDelegate
extension CurrencyUITextFieldDelegate: UITextFieldDelegate {
@discardableResult
open func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
return passthroughDelegate?.textFieldShouldBeginEditing?(textField) ?? true
}
public func textFieldDidBeginEditing(_ textField: UITextField) {
textField.setInitialSelectedTextRange()
passthroughDelegate?.textFieldDidBeginEditing?(textField)
}
@discardableResult
public func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
if let text = textField.text, text.representsZero && clearsWhenValueIsZero {
textField.text = ""
}
else if let text = textField.text, let updated = formatter.formattedStringAdjustedToFitAllowedValues(from: text), updated != text {
textField.text = updated
}
return passthroughDelegate?.textFieldShouldEndEditing?(textField) ?? true
}
open func textFieldDidEndEditing(_ textField: UITextField) {
passthroughDelegate?.textFieldDidEndEditing?(textField)
}
@discardableResult
open func textFieldShouldClear(_ textField: UITextField) -> Bool {
return passthroughDelegate?.textFieldShouldClear?(textField) ?? true
}
@discardableResult
open func textFieldShouldReturn(_ textField: UITextField) -> Bool {
return passthroughDelegate?.textFieldShouldReturn?(textField) ?? true
}
@discardableResult
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let shouldChangeCharactersInRange = passthroughDelegate?.textField?(textField,
shouldChangeCharactersIn: range,
replacementString: string) ?? true
guard shouldChangeCharactersInRange else {
return false
}
// Store selected text range offset from end, before updating and reformatting the currency string.
let lastSelectedTextRangeOffsetFromEnd = textField.selectedTextRangeOffsetFromEnd
// Before leaving the scope, update selected text range,
// respecting previous selected text range offset from end.
defer {
textField.updateSelectedTextRange(lastOffsetFromEnd: lastSelectedTextRangeOffsetFromEnd)
textUpdated?()
}
guard !string.isEmpty else {
handleDeletion(in: textField, at: range)
return false
}
guard string.hasNumbers else {
addNegativeSymbolIfNeeded(in: textField, at: range, replacementString: string)
return false
}
setFormattedText(in: textField, inputString: string, range: range)
return false
}
public func textFieldDidChangeSelection(_ textField: UITextField) {
if #available(iOSApplicationExtension 13.0, iOS 13.0, *) {
passthroughDelegate?.textFieldDidChangeSelection?(textField)
}
}
}
// MARK: - Private
extension CurrencyUITextFieldDelegate {
/// Verifies if user inputed a negative symbol at the first lowest
/// bound of the text field and add it.
///
/// - Parameters:
/// - textField: text field that user interacted with
/// - range: user input range
/// - string: user input string
private func addNegativeSymbolIfNeeded(in textField: UITextField, at range: NSRange, replacementString string: String) {
guard textField.keyboardType == .numbersAndPunctuation else { return }
if string == .negativeSymbol && textField.text?.isEmpty == true {
textField.text = .negativeSymbol
} else if range.lowerBound == 0 && string == .negativeSymbol &&
textField.text?.contains(String.negativeSymbol) == false {
textField.text = .negativeSymbol + (textField.text ?? "")
}
}
/// Correctly delete characters when user taps remove key.
///
/// - Parameters:
/// - textField: text field that user interacted with
/// - range: range to be removed
private func handleDeletion(in textField: UITextField, at range: NSRange) {
if var text = textField.text {
if let textRange = Range(range, in: text) {
text.removeSubrange(textRange)
} else {
text.removeLast()
}
if text.isEmpty {
textField.text = text
} else {
textField.text = formatter.formattedStringWithAdjustedDecimalSeparator(from: text)
}
}
}
/// Formats text field's text with new input string and changed range
///
/// - Parameters:
/// - textField: text field that user interacted with
/// - inputString: typed string
/// - range: range where the string should be added
private func setFormattedText(in textField: UITextField, inputString: String, range: NSRange) {
var updatedText = ""
if let text = textField.text {
if text.isEmpty {
updatedText = formatter.initialText + inputString
} else if let range = Range(range, in: text) {
updatedText = text.replacingCharacters(in: range, with: inputString)
} else {
updatedText = text.appending(inputString)
}
}
if updatedText.numeralFormat().count > formatter.maxDigitsCount {
updatedText.removeLast()
}
textField.text = formatter.formattedStringWithAdjustedDecimalSeparator(from: updatedText)
}
}
@@ -0,0 +1,61 @@
//
// UITextField.swift
// CurrencyText
//
// Created by Felipe Lefèvre Marino on 12/26/18.
//
import UIKit
public extension UITextField {
// MARK: Public
var selectedTextRangeOffsetFromEnd: Int {
return offset(from: endOfDocument, to: selectedTextRange?.end ?? endOfDocument)
}
/// Sets the selected text range when the text field is starting to be edited.
/// _Should_ be called when text field start to be the first responder.
func setInitialSelectedTextRange() {
// update selected text range if needed
adjustSelectedTextRange(lastOffsetFromEnd: 0) // at the end when first selected
}
/// Interface to update the selected text range as expected.
/// - Parameter lastOffsetFromEnd: The last stored selected text range offset from end. Used to keep it concise with pre-formatting.
func updateSelectedTextRange(lastOffsetFromEnd: Int) {
adjustSelectedTextRange(lastOffsetFromEnd: lastOffsetFromEnd)
}
// MARK: Private
/// Adjust the selected text range to match the best position.
private func adjustSelectedTextRange(lastOffsetFromEnd: Int) {
/// If text is empty the offset is set to zero, the selected text range does need to be changed.
if let text = text, text.isEmpty {
return
}
var offsetFromEnd = lastOffsetFromEnd
/// Adjust offset if needed. When the last number character offset from end is less than the current offset,
/// or in other words, is more distant to the end of the string, the offset is readjusted to it,
/// so the selected text range is correctly set to the last index with a number.
if let lastNumberOffsetFromEnd = text?.lastNumberOffsetFromEnd,
case let shouldOffsetBeAdjusted = lastNumberOffsetFromEnd < offsetFromEnd,
shouldOffsetBeAdjusted {
offsetFromEnd = lastNumberOffsetFromEnd
}
updateSelectedTextRange(offsetFromEnd: offsetFromEnd)
}
/// Update the selected text range with given offset from end.
private func updateSelectedTextRange(offsetFromEnd: Int) {
if let updatedCursorPosition = position(from: endOfDocument, offset: offsetFromEnd) {
selectedTextRange = textRange(from: updatedCursorPosition, to: updatedCursorPosition)
}
}
}