import Foundation import UIKit import AppBundle import AccountContext import TelegramPresentationData import AsyncDisplayKit import Display import Postbox import TelegramCore import ItemListUI import SwiftSignalKit import AlertUI import TextFormat private let walletAddressLength: Int = 48 private let balanceIcon = UIImage(bundleImageName: "Wallet/TransactionGem")?.precomposed() private final class WalletSendScreenArguments { let context: AccountContext let updateState: ((WalletSendScreenState) -> WalletSendScreenState) -> Void let selectNextInputItem: (WalletSendScreenEntryTag) -> Void let openQrScanner: () -> Void let proceed: () -> Void init(context: AccountContext, updateState: @escaping ((WalletSendScreenState) -> WalletSendScreenState) -> Void, selectNextInputItem: @escaping (WalletSendScreenEntryTag) -> Void, openQrScanner: @escaping () -> Void, proceed: @escaping () -> Void) { self.context = context self.updateState = updateState self.selectNextInputItem = selectNextInputItem self.openQrScanner = openQrScanner self.proceed = proceed } } private enum WalletSendScreenSection: Int32 { case address case amount case comment } private enum WalletSendScreenEntryTag: ItemListItemTag { case address case amount case comment func isEqual(to other: ItemListItemTag) -> Bool { if let other = other as? WalletSendScreenEntryTag { return self == other } else { return false } } } private let invalidAddressCharacters = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=").inverted private func isValidAddress(_ address: String, exactLength: Bool = false) -> Bool { if address.count > walletAddressLength || address.rangeOfCharacter(from: invalidAddressCharacters) != nil { return false } if exactLength && address.count != walletAddressLength { return false } return true } private let invalidAmountCharacters = CharacterSet(charactersIn: "01234567890.,").inverted private func isValidAmount(_ amount: String) -> Bool { if amount.rangeOfCharacter(from: invalidAmountCharacters) != nil { return false } var hasDecimalSeparator = false var hasLeadingZero = false var index = 0 for c in amount { if c == "." || c == "," { if !hasDecimalSeparator { hasDecimalSeparator = true } else { return false } } index += 1 } var decimalIndex: String.Index? if let index = amount.firstIndex(of: ".") { decimalIndex = index } else if let index = amount.firstIndex(of: ",") { decimalIndex = index } if let decimalIndex = decimalIndex, amount.distance(from: decimalIndex, to: amount.endIndex) > 10 { return false } return true } private func formatAmountText(_ amount: Int64, decimalSeparator: String = ".") -> String { if amount < 1000000000 { return "0\(decimalSeparator)\(String(amount).rightJustified(width: 9, pad: "0"))" } else { var string = String(amount) string.insert(contentsOf: decimalSeparator, at: string.index(string.endIndex, offsetBy: -9)) return string } } private func amountValue(_ string: String) -> Int64 { return Int64((Double(string.replacingOccurrences(of: ",", with: ".")) ?? 0.0) * 1000000000.0) } private func normalizedStringForGramsString(_ string: String, decimalSeparator: String = ".") -> String { return formatAmountText(amountValue(string), decimalSeparator: decimalSeparator) } private enum WalletSendScreenEntry: ItemListNodeEntry { case addressHeader(PresentationTheme, String) case address(PresentationTheme, String, String) case addressInfo(PresentationTheme, String) case amountHeader(PresentationTheme, String, String?, Bool) case amount(PresentationTheme, PresentationStrings, String, String) case commentHeader(PresentationTheme, String) case comment(PresentationTheme, String, String) var section: ItemListSectionId { switch self { case .addressHeader, .address, .addressInfo: return WalletSendScreenSection.address.rawValue case .amountHeader, .amount: return WalletSendScreenSection.amount.rawValue case .commentHeader, .comment: return WalletSendScreenSection.comment.rawValue } } var stableId: Int32 { switch self { case .addressHeader: return 0 case .address: return 1 case .addressInfo: return 2 case .amountHeader: return 3 case .amount: return 4 case .commentHeader: return 5 case .comment: return 6 } } static func ==(lhs: WalletSendScreenEntry, rhs: WalletSendScreenEntry) -> Bool { switch lhs { case let .addressHeader(lhsTheme, lhsText): if case let .addressHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false } case let .address(lhsTheme, lhsPlaceholder, lhsAddress): if case let .address(rhsTheme, rhsPlaceholder, rhsAddress) = rhs, lhsTheme === rhsTheme, lhsPlaceholder == rhsPlaceholder, lhsAddress == rhsAddress { return true } else { return false } case let .addressInfo(lhsTheme, lhsText): if case let .addressInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false } case let .amountHeader(lhsTheme, lhsText, lhsBalance, lhsInsufficient): if case let .amountHeader(rhsTheme, rhsText, rhsBalance, rhsInsufficient) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsBalance == rhsBalance, lhsInsufficient == rhsInsufficient { return true } else { return false } case let .amount(lhsTheme, lhsStrings, lhsPlaceholder, lhsBalance): if case let .amount(rhsTheme, rhsStrings, rhsPlaceholder, rhsBalance) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsPlaceholder == rhsPlaceholder, lhsBalance == rhsBalance { return true } else { return false } case let .commentHeader(lhsTheme, lhsText): if case let .commentHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false } case let .comment(lhsTheme, lhsPlaceholder, lhsText): if case let .comment(rhsTheme, rhsPlaceholder, rhsText) = rhs, lhsTheme === rhsTheme, lhsPlaceholder == rhsPlaceholder, lhsText == rhsText { return true } else { return false } } } static func <(lhs: WalletSendScreenEntry, rhs: WalletSendScreenEntry) -> Bool { return lhs.stableId < rhs.stableId } func item(_ arguments: WalletSendScreenArguments) -> ListViewItem { switch self { case let .addressHeader(theme, text): return ItemListSectionHeaderItem(theme: theme, text: text, sectionId: self.section) case let .address(theme, placeholder, address): return ItemListMultilineInputItem(theme: theme, text: address, placeholder: placeholder, maxLength: .init(value: walletAddressLength, display: false), sectionId: self.section, style: .blocks, capitalization: false, autocorrection: false, returnKeyType: .next, minimalHeight: 68.0, textUpdated: { address in arguments.updateState { state in var state = state state.address = address.replacingOccurrences(of: "\n", with: "") return state } }, shouldUpdateText: { text in return isValidAddress(text) }, tag: WalletSendScreenEntryTag.address, action: { arguments.selectNextInputItem(WalletSendScreenEntryTag.address) }, inlineAction: ItemListMultilineInputInlineAction(icon: UIImage(bundleImageName: "Wallet/QrIcon")!, action: { arguments.openQrScanner() })) case let .addressInfo(theme, text): return ItemListTextItem(theme: theme, text: .markdown(text), sectionId: self.section) case let .amountHeader(theme, text, balance, insufficient): return ItemListSectionHeaderItem(theme: theme, text: text, activityIndicator: balance == nil ? .right : .none, accessoryText: balance.flatMap { ItemListSectionHeaderAccessoryText(value: $0, color: insufficient ? .destructive : .generic, icon: balanceIcon) }, sectionId: self.section) case let .amount(theme, strings, placeholder, text): return ItemListSingleLineInputItem(theme: theme, strings: strings, title: NSAttributedString(string: ""), text: text, placeholder: placeholder, type: .decimal, returnKeyType: .next, tag: WalletSendScreenEntryTag.amount, sectionId: self.section, textUpdated: { text in arguments.updateState { state in var state = state state.amount = text return state } }, shouldUpdateText: { text in return isValidAmount(text) }, processPaste: { pastedText in if isValidAmount(pastedText) { return normalizedStringForGramsString(pastedText) } else { return text } }, updatedFocus: { focus in if !focus { let presentationData = arguments.context.sharedContext.currentPresentationData.with { $0 } arguments.updateState { state in var state = state if !state.amount.isEmpty { state.amount = normalizedStringForGramsString(state.amount, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) } return state } } }, action: { arguments.selectNextInputItem(WalletSendScreenEntryTag.amount) }) case let .commentHeader(theme, text): return ItemListSectionHeaderItem(theme: theme, text: text, sectionId: self.section) case let .comment(theme, placeholder, value): return ItemListMultilineInputItem(theme: theme, text: value, placeholder: placeholder, maxLength: ItemListMultilineInputItemTextLimit(value: 128, display: true), sectionId: self.section, style: .blocks, returnKeyType: .send, textUpdated: { comment in arguments.updateState { state in var state = state state.comment = comment return state } }, tag: WalletSendScreenEntryTag.comment, action: { arguments.proceed() }) } } } private struct WalletSendScreenState: Equatable { var address: String var amount: String var comment: String var qrScanAvailable: Bool } private func walletSendScreenEntries(presentationData: PresentationData, balance: Int64?, state: WalletSendScreenState) -> [WalletSendScreenEntry] { var entries: [WalletSendScreenEntry] = [] entries.append(.addressHeader(presentationData.theme, "RECIPIENT WALLET ADDRESS")) entries.append(.address(presentationData.theme, "Enter wallet address...", state.address)) entries.append(.addressInfo(presentationData.theme, "Copy the 48-letter address of the recipient here or ask them to send you a ton:// link.")) let amount = amountValue(state.amount) entries.append(.amountHeader(presentationData.theme, "AMOUNT", balance.flatMap { "BALANCE: \(formatBalanceText($0, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator))" }, amount > 0 && (balance ?? 0) < amount)) entries.append(.amount(presentationData.theme, presentationData.strings, "Grams to send", state.amount ?? "")) entries.append(.commentHeader(presentationData.theme, "COMMENT")) entries.append(.comment(presentationData.theme, "Optional description of the payment", state.comment)) return entries } protocol WalletSendScreen { } private final class WalletSendScreenImpl: ItemListController, WalletSendScreen { } func walletSendScreen(context: AccountContext, tonContext: TonContext, walletInfo: WalletInfo, walletState: WalletState? = nil, address: String? = nil, amount: Int64? = nil) -> ViewController { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let initialState = WalletSendScreenState(address: address ?? "", amount: amount.flatMap { formatAmountText($0, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) } ?? "", comment: "", qrScanAvailable: address == nil) let statePromise = ValuePromise(initialState, ignoreRepeated: true) let stateValue = Atomic(value: initialState) let updateState: ((WalletSendScreenState) -> WalletSendScreenState) -> Void = { f in statePromise.set(stateValue.modify { f($0) }) } var presentControllerImpl: ((ViewController, Any?) -> Void)? var presentInGlobalOverlayImpl: ((ViewController, Any?) -> Void)? var pushImpl: ((ViewController) -> Void)? var popImpl: (() -> Void)? var dismissImpl: (() -> Void)? var dismissInputImpl: (() -> Void)? var selectNextInputItemImpl: ((WalletSendScreenEntryTag) -> Void)? let arguments = WalletSendScreenArguments(context: context, updateState: { f in updateState(f) }, selectNextInputItem: { tag in selectNextInputItemImpl?(tag) }, openQrScanner: { dismissInputImpl?() pushImpl?(WalletQrScanScreen(context: context, completion: { address, amount, comment in var updatedState: WalletSendScreenState? updateState { state in var state = state state.address = address if let amount = amount { state.amount = formatAmountText(amount, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) } if let comment = comment { state.comment = comment } state.qrScanAvailable = false updatedState = state return state } popImpl?() if let updatedState = updatedState { if updatedState.amount.isEmpty { selectNextInputItemImpl?(WalletSendScreenEntryTag.address) } else if updatedState.comment.isEmpty { selectNextInputItemImpl?(WalletSendScreenEntryTag.amount) } } })) }, proceed: { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let state = stateValue.with { $0 } let amount = amountValue(state.amount) updateState { state in var state = state state.amount = formatAmountText(amount, decimalSeparator: presentationData.dateTimeFormat.decimalSeparator) return state } let title = NSAttributedString(string: "Confirmation", font: Font.semibold(17.0), textColor: presentationData.theme.actionSheet.primaryTextColor) let address = state.address[state.address.startIndex.. Void)? let controller = richTextAlertController(context: context, title: title, text: attributedText, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { dismissAlertImpl?(true) }), TextAlertAction(type: .defaultAction, title: "Confirm", action: { dismissAlertImpl?(false) pushImpl?(WalletSplashScreen(context: context, tonContext: tonContext, mode: .sending(walletInfo, state.address, amount, state.comment))) })], dismissAutomatically: false) presentInGlobalOverlayImpl?(controller, nil) dismissAlertImpl = { [weak controller] animated in if animated { controller?.dismissAnimated() } else { controller?.dismiss() } } }) let balance: Signal = .single(walletState) |> then(walletAddress(publicKey: walletInfo.publicKey, tonInstance: tonContext.instance) |> mapToSignal { address in return getWalletState(address: address, tonInstance: tonContext.instance) |> map(Optional.init) }) var focusItemTag: ItemListItemTag? if address == nil { focusItemTag = WalletSendScreenEntryTag.address } else if amount == nil { focusItemTag = WalletSendScreenEntryTag.amount } let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, balance, statePromise.get()) |> map { presentationData, balance, state -> (ItemListControllerState, (ItemListNodeState, WalletSendScreenEntry.ItemGenerationArguments)) in let leftNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: { dismissImpl?() }) let amount = amountValue(state.amount) var sendEnabled = false if let balance = balance { sendEnabled = isValidAddress(state.address, exactLength: true) && amount > 0 && amount <= balance.balance } let rightNavigationButton = ItemListNavigationButton(content: .text("Send"), style: .bold, enabled: sendEnabled, action: { arguments.proceed() }) let controllerState = ItemListControllerState(theme: presentationData.theme, title: .text("Send Grams"), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) let listState = ItemListNodeState(entries: walletSendScreenEntries(presentationData: presentationData, balance: balance?.balance, state: state), style: .blocks, focusItemTag: focusItemTag, animateChanges: false) return (controllerState, (listState, arguments)) } let controller = WalletSendScreenImpl(context: context, state: signal) controller.navigationPresentation = .modalInLargeLayout presentControllerImpl = { [weak controller] c, a in controller?.present(c, in: .window(.root), with: a) } presentInGlobalOverlayImpl = { [weak controller] c, a in controller?.presentInGlobalOverlay(c, with: a) } pushImpl = { [weak controller] c in controller?.push(c) } popImpl = { [weak controller] in (controller?.navigationController as? NavigationController)?.popViewController(animated: true) } dismissImpl = { [weak controller] in controller?.view.endEditing(true) let _ = controller?.dismiss() } dismissInputImpl = { [weak controller] in controller?.view.endEditing(true) } selectNextInputItemImpl = { [weak controller] currentTag in guard let controller = controller else { return } var resultItemNode: ItemListItemFocusableNode? var focusOnNext = false let _ = controller.frameForItemNode({ itemNode in if let itemNode = itemNode as? ItemListItemNode, let tag = itemNode.tag, let focusableItemNode = itemNode as? ItemListItemFocusableNode { if focusOnNext && resultItemNode == nil { resultItemNode = focusableItemNode return true } else if currentTag.isEqual(to: tag) { focusOnNext = true } } return false }) if let resultItemNode = resultItemNode { resultItemNode.focus() } } return controller }