From 20325dd69c846fff63bc710dc73daf6a6a0c0afa Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 2 Apr 2021 19:16:25 +0400 Subject: [PATCH] Initial tip support --- .../Sources/BotCheckoutControllerNode.swift | 116 +++--- .../Sources/BotCheckoutPriceItem.swift | 51 ++- .../Sources/BotCheckoutTipItem.swift | 355 ++++++++++++++++++ .../Sources/BotReceiptControllerNode.swift | 14 +- 4 files changed, 473 insertions(+), 63 deletions(-) create mode 100644 submodules/BotPaymentsUI/Sources/BotCheckoutTipItem.swift diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift index 1ae22e4721..129b8c457a 100644 --- a/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift @@ -24,14 +24,14 @@ final class BotCheckoutControllerArguments { fileprivate let openInfo: (BotCheckoutInfoControllerFocus) -> Void fileprivate let openPaymentMethod: () -> Void fileprivate let openShippingMethod: () -> Void - fileprivate let openTip: () -> Void + fileprivate let updateTip: (Int64) -> Void - fileprivate init(account: Account, openInfo: @escaping (BotCheckoutInfoControllerFocus) -> Void, openPaymentMethod: @escaping () -> Void, openShippingMethod: @escaping () -> Void, openTip: @escaping () -> Void) { + fileprivate init(account: Account, openInfo: @escaping (BotCheckoutInfoControllerFocus) -> Void, openPaymentMethod: @escaping () -> Void, openShippingMethod: @escaping () -> Void, updateTip: @escaping (Int64) -> Void) { self.account = account self.openInfo = openInfo self.openPaymentMethod = openPaymentMethod self.openShippingMethod = openShippingMethod - self.openTip = openTip + self.updateTip = updateTip } } @@ -43,8 +43,8 @@ private enum BotCheckoutSection: Int32 { enum BotCheckoutEntry: ItemListNodeEntry { case header(PresentationTheme, TelegramMediaInvoice, String) - case price(Int, PresentationTheme, String, String, Bool) - case tip(PresentationTheme, String, String) + case price(Int, PresentationTheme, String, String, Bool, Bool) + case tip(Int, PresentationTheme, String, String, String, Int64, [(String, Int64)]) case paymentMethod(PresentationTheme, String, String) case shippingInfo(PresentationTheme, String, String) case shippingMethod(PresentationTheme, String, String) @@ -56,7 +56,7 @@ enum BotCheckoutEntry: ItemListNodeEntry { switch self { case .header: return BotCheckoutSection.header.rawValue - case .price: + case .price, .tip: return BotCheckoutSection.prices.rawValue default: return BotCheckoutSection.info.rawValue @@ -67,10 +67,10 @@ enum BotCheckoutEntry: ItemListNodeEntry { switch self { case .header: return 0 - case let .price(index, _, _, _, _): + case let .price(index, _, _, _, _, _): + return 1 + Int32(index) + case let .tip(index, _, _, _, _, _, _): return 1 + Int32(index) - case .tip: - return 10000 + 1 case .paymentMethod: return 10000 + 2 case .shippingInfo: @@ -103,8 +103,8 @@ enum BotCheckoutEntry: ItemListNodeEntry { } else { return false } - case let .price(lhsIndex, lhsTheme, lhsText, lhsValue, lhsFinal): - if case let .price(rhsIndex, rhsTheme, rhsText, rhsValue, rhsFinal) = rhs { + case let .price(lhsIndex, lhsTheme, lhsText, lhsValue, lhsFinal, lhsHasSeparator): + if case let .price(rhsIndex, rhsTheme, rhsText, rhsValue, rhsFinal, rhsHasSeparator) = rhs { if lhsIndex != rhsIndex { return false } @@ -120,12 +120,26 @@ enum BotCheckoutEntry: ItemListNodeEntry { if lhsFinal != rhsFinal { return false } + if lhsHasSeparator != rhsHasSeparator { + return false + } return true } else { return false } - case let .tip(lhsTheme, lhsText, lhsValue): - if case let .tip(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + case let .tip(lhsIndex, lhsTheme, lhsText, lhsCurrency, lhsValue, lhsNumericValue, lhsVariants): + if case let .tip(rhsIndex, rhsTheme, rhsText, rhsCurrency, rhsValue, rhsNumericValue, rhsVariants) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, lhsText == rhsText, lhsCurrency == rhsCurrency, lhsValue == rhsValue, lhsNumericValue == rhsNumericValue { + if lhsVariants.count != rhsVariants.count { + return false + } + for i in 0 ..< lhsVariants.count { + if lhsVariants[i].0 != rhsVariants[i].0 { + return false + } + if lhsVariants[i].1 != rhsVariants[i].1 { + return false + } + } return true } else { return false @@ -178,11 +192,11 @@ enum BotCheckoutEntry: ItemListNodeEntry { switch self { case let .header(theme, invoice, botName): return BotCheckoutHeaderItem(account: arguments.account, theme: theme, invoice: invoice, botName: botName, sectionId: self.section) - case let .price(_, theme, text, value, isFinal): - return BotCheckoutPriceItem(theme: theme, title: text, label: value, isFinal: isFinal, sectionId: self.section) - case let .tip(_, text, value): - return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .arrow, action: { - arguments.openTip() + case let .price(_, theme, text, value, isFinal, hasSeparator): + return BotCheckoutPriceItem(theme: theme, title: text, label: value, isFinal: isFinal, hasSeparator: hasSeparator, sectionId: self.section) + case let .tip(_, _, text, currency, value, numericValue, variants): + return BotCheckoutTipItem(theme: presentationData.theme, title: text, currency: currency, value: value, numericValue: numericValue, availableVariants: variants, sectionId: self.section, updateValue: { value in + arguments.updateTip(value) }) case let .paymentMethod(_, text, value): return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .arrow, action: { @@ -272,7 +286,7 @@ private func botCheckoutControllerEntries(presentationData: PresentationData, st var index = 0 for price in paymentForm.invoice.prices { - entries.append(.price(index, presentationData.theme, price.label, formatCurrencyAmount(price.amount, currency: paymentForm.invoice.currency), false)) + entries.append(.price(index, presentationData.theme, price.label, formatCurrencyAmount(price.amount, currency: paymentForm.invoice.currency), false, false)) totalPrice += price.amount index += 1 } @@ -286,7 +300,7 @@ private func botCheckoutControllerEntries(presentationData: PresentationData, st shippingOptionString = option.title for price in option.prices { - entries.append(.price(index, presentationData.theme, price.label, formatCurrencyAmount(price.amount, currency: paymentForm.invoice.currency), false)) + entries.append(.price(index, presentationData.theme, price.label, formatCurrencyAmount(price.amount, currency: paymentForm.invoice.currency), false, false)) totalPrice += price.amount index += 1 } @@ -296,16 +310,28 @@ private func botCheckoutControllerEntries(presentationData: PresentationData, st } } } - - entries.append(.price(index, presentationData.theme, presentationData.strings.Checkout_TotalAmount, formatCurrencyAmount(totalPrice, currency: paymentForm.invoice.currency), true)) + + if !entries.isEmpty { + switch entries[entries.count - 1] { + case let .price(index, theme, title, value, _, _): + entries[entries.count - 1] = .price(index, theme, title, value, false, false) + default: + break + } + } if let tip = paymentForm.invoice.tip { let tipTitle: String //TODO:localize - tipTitle = "Tip" - entries.append(.tip(presentationData.theme, tipTitle, "\(formatCurrencyAmount(currentTip ?? 0, currency: paymentForm.invoice.currency))")) + tipTitle = "Tip (Optional)" + entries.append(.tip(index, presentationData.theme, tipTitle, paymentForm.invoice.currency, "\(formatCurrencyAmount(currentTip ?? 0, currency: paymentForm.invoice.currency))", currentTip ?? 0, tip.suggested.map { item -> (String, Int64) in + return ("\(formatCurrencyAmount(item, currency: paymentForm.invoice.currency))", item) + })) + index += 1 } + entries.append(.price(index, presentationData.theme, presentationData.strings.Checkout_TotalAmount, formatCurrencyAmount(totalPrice, currency: paymentForm.invoice.currency), true, true)) + var paymentMethodTitle = "" if let currentPaymentMethod = currentPaymentMethod { paymentMethodTitle = currentPaymentMethod.title @@ -439,7 +465,7 @@ final class BotCheckoutControllerNode: ItemListControllerNode, PKPaymentAuthoriz self.presentationData = context.sharedContext.currentPresentationData.with { $0 } var openInfoImpl: ((BotCheckoutInfoControllerFocus) -> Void)? - var openTipImpl: (() -> Void)? + var updateTipImpl: ((Int64) -> Void)? var openPaymentMethodImpl: (() -> Void)? var openShippingMethodImpl: (() -> Void)? @@ -449,8 +475,8 @@ final class BotCheckoutControllerNode: ItemListControllerNode, PKPaymentAuthoriz openPaymentMethodImpl?() }, openShippingMethod: { openShippingMethodImpl?() - }, openTip: { - openTipImpl?() + }, updateTip: { value in + updateTipImpl?(value) }) let signal: Signal<(ItemListPresentationData, (ItemListNodeState, Any)), NoError> = combineLatest(context.sharedContext.presentationData, self.state.get(), paymentFormAndInfo.get(), context.account.postbox.loadedPeerWithId(messageId.peerId)) @@ -643,30 +669,20 @@ final class BotCheckoutControllerNode: ItemListControllerNode, PKPaymentAuthoriz } } - openTipImpl = { [weak self] in - if let strongSelf = self, let paymentFormValue = strongSelf.paymentFormValue { - //TODO:localize - let initialValue: String - if let tipAmount = strongSelf.currentTipAmount, let value = currencyToFractionalAmount(value: tipAmount, currency: paymentFormValue.invoice.currency) { - initialValue = "\(value)" - } else { - initialValue = "0" - } - let controller = tipEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: nil, title: "Tip", text: "Enter Tip Amount", placeholder: "", value: initialValue, apply: { value in - guard let strongSelf = self, let paymentFormValue = strongSelf.paymentFormValue, let currentFormInfo = strongSelf.currentFormInfo, let value = value else { - return - } - - let tipAmount = fractionalToCurrencyAmount(value: (Double(value) ?? 0.0), currency: paymentFormValue.invoice.currency) ?? 0 - - strongSelf.currentTipAmount = tipAmount - - strongSelf.paymentFormAndInfo.set(.single((paymentFormValue, currentFormInfo, strongSelf.currentValidatedFormInfo, strongSelf.currentShippingOptionId, strongSelf.currentPaymentMethod, strongSelf.currentTipAmount))) - - strongSelf.updateActionButton() - }) - strongSelf.present(controller, nil) + updateTipImpl = { [weak self] value in + guard let strongSelf = self, let paymentFormValue = strongSelf.paymentFormValue, let currentFormInfo = strongSelf.currentFormInfo else { + return } + + if strongSelf.currentTipAmount == value { + return + } + + strongSelf.currentTipAmount = value + + strongSelf.paymentFormAndInfo.set(.single((paymentFormValue, currentFormInfo, strongSelf.currentValidatedFormInfo, strongSelf.currentShippingOptionId, strongSelf.currentPaymentMethod, strongSelf.currentTipAmount))) + + strongSelf.updateActionButton() } openPaymentMethodImpl = { [weak self] in diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutPriceItem.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutPriceItem.swift index 46df1456df..7e711a4ffe 100644 --- a/submodules/BotPaymentsUI/Sources/BotCheckoutPriceItem.swift +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutPriceItem.swift @@ -12,15 +12,17 @@ class BotCheckoutPriceItem: ListViewItem, ItemListItem { let title: String let label: String let isFinal: Bool + let hasSeparator: Bool let sectionId: ItemListSectionId let requestsNoInset: Bool = true - init(theme: PresentationTheme, title: String, label: String, isFinal: Bool, sectionId: ItemListSectionId) { + init(theme: PresentationTheme, title: String, label: String, isFinal: Bool, hasSeparator: Bool, sectionId: ItemListSectionId) { self.theme = theme self.title = title self.label = label self.isFinal = isFinal + self.hasSeparator = hasSeparator self.sectionId = sectionId } @@ -83,6 +85,10 @@ private func priceItemInsets(_ neighbors: ItemListNeighbors) -> UIEdgeInsets { class BotCheckoutPriceItemNode: ListViewItemNode { let titleNode: TextNode let labelNode: TextNode + + let separatorNode: ASDisplayNode + let bottomSeparatorNode: ASDisplayNode + let spacerNode: ASDisplayNode private var item: BotCheckoutPriceItem? @@ -92,11 +98,18 @@ class BotCheckoutPriceItemNode: ListViewItemNode { self.labelNode = TextNode() self.labelNode.isUserInteractionEnabled = false + + self.separatorNode = ASDisplayNode() + self.bottomSeparatorNode = ASDisplayNode() + self.spacerNode = ASDisplayNode() super.init(layerBacked: false, dynamicBounce: false) - + + self.addSubnode(self.spacerNode) self.addSubnode(self.titleNode) self.addSubnode(self.labelNode) + self.addSubnode(self.separatorNode) + self.addSubnode(self.bottomSeparatorNode) } func asyncLayout() -> (_ item: BotCheckoutPriceItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { @@ -105,9 +118,18 @@ class BotCheckoutPriceItemNode: ListViewItemNode { return { item, params, neighbors in let rightInset: CGFloat = 16.0 + params.rightInset + + let naturalContentHeight: CGFloat = 34.0 - let contentSize = CGSize(width: params.width, height: 34.0) - let insets = priceItemInsets(neighbors) + var contentSize = CGSize(width: params.width, height: naturalContentHeight) + var insets = priceItemInsets(neighbors) + + if item.hasSeparator { + insets.top += 5.0 + } + if item.isFinal { + contentSize.height += 34.0 + } let textFont: UIFont let textColor: UIColor @@ -130,9 +152,26 @@ class BotCheckoutPriceItemNode: ListViewItemNode { let _ = labelApply() let leftInset: CGFloat = 16.0 + params.leftInset + + strongSelf.separatorNode.isHidden = !item.hasSeparator + strongSelf.separatorNode.backgroundColor = item.theme.list.itemPlainSeparatorColor + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: params.width - leftInset, height: UIScreenPixel)) + + strongSelf.bottomSeparatorNode.isHidden = !item.isFinal + strongSelf.bottomSeparatorNode.backgroundColor = item.theme.list.itemPlainSeparatorColor + strongSelf.bottomSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: naturalContentHeight + 10.0), size: CGSize(width: params.width, height: UIScreenPixel)) + + strongSelf.spacerNode.isHidden = !item.isFinal + strongSelf.spacerNode.backgroundColor = item.theme.list.blocksBackgroundColor + strongSelf.spacerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: naturalContentHeight + 10.0 + UIScreenPixel), size: CGSize(width: params.width, height: max(0.0, contentSize.height - naturalContentHeight - UIScreenPixel))) + + var verticalOffset: CGFloat = 0.0 + if item.hasSeparator { + verticalOffset += 5.0 + } - strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((contentSize.height - titleLayout.size.height) / 2.0)), size: titleLayout.size) - strongSelf.labelNode.frame = CGRect(origin: CGPoint(x: params.width - rightInset - labelLayout.size.width, y: floor((contentSize.height - labelLayout.size.height) / 2.0)), size: labelLayout.size) + 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) } }) } diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutTipItem.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutTipItem.swift new file mode 100644 index 0000000000..f459b758c9 --- /dev/null +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutTipItem.swift @@ -0,0 +1,355 @@ +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 title: String + let currency: String + let value: String + let numericValue: Int64 + let availableVariants: [(String, Int64)] + let updateValue: (Int64) -> Void + + let sectionId: ItemListSectionId + + let requestsNoInset: Bool = true + + init(theme: PresentationTheme, title: String, currency: String, value: String, numericValue: Int64, availableVariants: [(String, Int64)], sectionId: ItemListSectionId, updateValue: @escaping (Int64) -> Void) { + self.theme = theme + self.title = title + self.currency = currency + self.value = value + self.numericValue = numericValue + self.availableVariants = availableVariants + self.updateValue = updateValue + self.sectionId = sectionId + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (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 { + var updateBackground = false + let backgroundColor = isHighlighted ? UIColor(rgb: 0x00A650) : UIColor(rgb: 0xE5F6ED) + 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 ? UIColor(rgb: 0xffffff) : UIColor(rgb: 0x00A650)) + 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) + + 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) + + return size.width + } +} + +class BotCheckoutTipItemNode: ListViewItemNode, UITextFieldDelegate { + let titleNode: TextNode + let labelNode: TextNode + private let textNode: TextFieldNode + + private let scrollNode: ASScrollNode + private var valueNodes: [TipValueNode] = [] + + private var item: BotCheckoutTipItem? + + init() { + self.titleNode = TextNode() + self.titleNode.isUserInteractionEnabled = false + + self.labelNode = TextNode() + self.labelNode.isUserInteractionEnabled = false + + 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 + } + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.titleNode) + self.addSubnode(self.labelNode) + self.addSubnode(self.textNode) + self.addSubnode(self.scrollNode) + + self.textNode.clipsToBounds = true + self.textNode.textField.delegate = self + 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: "Enter Custom", 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) + + let text: String + if item.numericValue == 0 { + text = "" + } else { + text = formatCurrencyAmount(item.numericValue, currency: item.currency) + } + if strongSelf.textNode.textField.text ?? "" != text { + strongSelf.textNode.textField.text = text + strongSelf.labelNode.isHidden = !text.isEmpty + } + + 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.tintColor = item.theme.list.itemAccentColor + + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: params.width - leftInset - 150.0, y: -2.0), size: CGSize(width: 150.0, height: labelsContentHeight)) + + let valueHeight: CGFloat = 52.0 + let valueY: CGFloat = labelsContentHeight + 9.0 + + var index = 0 + var variantsOffset: CGFloat = 16.0 + for (variantText, variantValue) in item.availableVariants { + if index != 0 { + variantsOffset += 12.0 + } + + let valueNode: TipValueNode + if strongSelf.valueNodes.count > index { + valueNode = strongSelf.valueNodes[index] + } else { + valueNode = TipValueNode() + strongSelf.valueNodes.append(valueNode) + strongSelf.scrollNode.addSubnode(valueNode) + } + let nodeWidth = valueNode.update(theme: item.theme, text: variantText, isHighlighted: item.value == variantText, height: valueHeight) + valueNode.action = { + guard let strongSelf = self else { + return + } + strongSelf.item?.updateValue(variantValue) + } + valueNode.frame = CGRect(origin: CGPoint(x: variantsOffset, y: 0.0), size: CGSize(width: nodeWidth, height: valueHeight)) + variantsOffset += nodeWidth + index += 1 + } + + 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) + } + }) + } + } + + @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 let value = fractionalToCurrencyAmount(value: doubleValue, currency: item.currency) { + item.updateValue(value) + } + } + + @objc public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + guard let item = self.item else { + return false + } + let newText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + + return true + } + + @objc public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + return false + } + + @objc public func textFieldDidBeginEditing(_ textField: UITextField) { + } + + @objc public func textFieldDidEndEditing(_ textField: UITextField) { + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + 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) + } +} diff --git a/submodules/BotPaymentsUI/Sources/BotReceiptControllerNode.swift b/submodules/BotPaymentsUI/Sources/BotReceiptControllerNode.swift index 66f7a19f37..c7159fb6bf 100644 --- a/submodules/BotPaymentsUI/Sources/BotReceiptControllerNode.swift +++ b/submodules/BotPaymentsUI/Sources/BotReceiptControllerNode.swift @@ -155,18 +155,18 @@ enum BotReceiptEntry: ItemListNodeEntry { case let .header(theme, invoice, botName): return BotCheckoutHeaderItem(account: arguments.account, theme: theme, invoice: invoice, botName: botName, sectionId: self.section) case let .price(_, theme, text, value, isFinal): - return BotCheckoutPriceItem(theme: theme, title: text, label: value, isFinal: isFinal, sectionId: self.section) - case let .paymentMethod(theme, text, value): + return BotCheckoutPriceItem(theme: theme, title: text, label: value, isFinal: isFinal, hasSeparator: false, 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(theme, text, value): + 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(theme, text, value): + 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(theme, text, value): + 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(theme, text, value): + 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(theme, text, value): + case let .phoneInfo(_, text, value): return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: nil) } }