Signal-iOS/Signal/src/ViewControllers/Payments/SendPaymentViewController.swift
2021-11-02 14:26:04 -03:00

1600 lines
65 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import UIKit
import Lottie
@objc
public protocol SendPaymentViewDelegate {
func didSendPayment()
}
// MARK: -
@objc
public enum SendPaymentMode: UInt {
case fromConversationView
case fromPaymentSettings
case fromTransferOutFlow
var isModalRootView: Bool {
switch self {
case .fromConversationView:
return true
case .fromPaymentSettings,
.fromTransferOutFlow:
return false
}
}
}
// MARK: -
@objc
public class SendPaymentViewController: OWSViewController {
private let mode: SendPaymentMode
fileprivate typealias PaymentInfo = SendPaymentInfo
@objc
public weak var delegate: SendPaymentViewDelegate?
private let recipient: SendPaymentRecipient
private let paymentRequestModel: TSPaymentRequestModel?
private let isOutgoingTransfer: Bool
private let rootStack = UIStackView()
private let bigAmountLabel = UILabel()
private let smallAmountLabel = UILabel()
private let currencyConversionInfoView = UIImageView()
private let balanceLabel = SendPaymentHelper.buildBottomLabel()
// MARK: - Amount
private let amounts = Amounts()
private var amount: Amount { amounts.currentAmount }
private var otherCurrencyAmount: Amount? { amounts.otherCurrencyAmount }
// MARK: -
private var memoMessage: String?
private var hasMemoMessage: Bool {
memoMessage?.strippedOrNil != nil
}
private var helper: SendPaymentHelper?
private var currentCurrencyConversion: CurrencyConversionInfo? { helper?.currentCurrencyConversion }
private var isIdentifiedPayment: Bool {
recipient.isIdentifiedPayment
}
public required init(recipient: SendPaymentRecipient,
paymentRequestModel: TSPaymentRequestModel?,
initialPaymentAmount: TSPaymentAmount?,
isOutgoingTransfer: Bool,
mode: SendPaymentMode) {
self.recipient = recipient
self.mode = mode
self.isOutgoingTransfer = isOutgoingTransfer
if Self.wasLastPaymentInFiat,
let defaultFiatAmount = Amounts.defaultFiatAmount {
amounts.set(currentAmount: defaultFiatAmount, otherCurrencyAmount: nil)
} else {
amounts.set(currentAmount: Amounts.defaultMCAmount, otherCurrencyAmount: nil)
}
if !FeatureFlags.paymentsRequests {
owsAssertDebug(paymentRequestModel == nil)
self.paymentRequestModel = nil
} else {
self.paymentRequestModel = paymentRequestModel
if let paymentRequestModel = paymentRequestModel {
owsAssertDebug(paymentRequestModel.paymentAmount.currency == .mobileCoin)
if let requestAmountString = PaymentsFormat.formatAsDoubleString(picoMob: paymentRequestModel.paymentAmount.picoMob) {
let inputString = InputString.parseString(requestAmountString, isFiat: false)
amounts.set(currentAmount: .mobileCoin(inputString: inputString,
exactAmount: nil),
otherCurrencyAmount: nil)
} else {
owsFailDebug("Could not apply request amount.")
}
}
}
if let initialPaymentAmount = initialPaymentAmount {
owsAssertDebug(initialPaymentAmount.currency == .mobileCoin)
if let amountString = PaymentsFormat.formatAsDoubleString(picoMob: initialPaymentAmount.picoMob) {
let inputString = InputString.parseString(amountString, isFiat: false)
amounts.set(currentAmount: .mobileCoin(inputString: inputString,
exactAmount: initialPaymentAmount),
otherCurrencyAmount: nil)
} else {
owsFailDebug("Could not apply initial amount.")
}
}
super.init()
helper = SendPaymentHelper(delegate: self)
amounts.delegate = self
}
private enum PresentationMode {
case fromConversationView(fromViewController: UIViewController)
case inNavigationController(navigationController: UINavigationController)
}
private static func present(fromViewController: UIViewController,
presentationMode: PresentationMode,
delegate: SendPaymentViewDelegate,
recipientAddress: SignalServiceAddress,
paymentRequestModel: TSPaymentRequestModel?,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool,
mode: SendPaymentMode) {
guard paymentsHelper.arePaymentsEnabled else {
Logger.info("Payments not enabled.")
showEnablePaymentsActionSheet()
return
}
guard tsAccountManager.isRegisteredAndReady else {
Logger.info("Local user is not registered and ready.")
showNotRegisteredActionSheet()
return
}
var hasProfileKeyForRecipient = false
var hasSentMessagesToRecipient = false
databaseStorage.read { transaction in
guard nil == Self.profileManager.profileKeyData(for: recipientAddress,
transaction: transaction) else {
hasProfileKeyForRecipient = true
return
}
guard let thread = TSContactThread.getWithContactAddress(recipientAddress,
transaction: transaction) else {
hasSentMessagesToRecipient = false
return
}
let interactionFinder = InteractionFinder(threadUniqueId: thread.uniqueId)
hasSentMessagesToRecipient = 0 < interactionFinder.outgoingMessageCount(transaction: transaction)
}
guard hasProfileKeyForRecipient else {
let title = NSLocalizedString("PAYMENTS_RECIPIENT_MISSING_PROFILE_KEY_TITLE",
comment: "Title for error alert indicating that a given user cannot receive payments because of a pending message request.")
let message = (hasSentMessagesToRecipient
? NSLocalizedString("PAYMENTS_RECIPIENT_MISSING_PROFILE_KEY_MESSAGE_W_MESSAGES",
comment: "Message for error alert indicating that a given user cannot receive payments because of a pending message request for a recipient that they have sent messages to.")
: NSLocalizedString("PAYMENTS_RECIPIENT_MISSING_PROFILE_KEY_MESSAGE_WO_MESSAGES",
comment: "Message for error alert indicating that a given user cannot receive payments because of a pending message request for a recipient that they have not sent message to."))
let actionSheet = ActionSheetController(title: title, message: message)
if !hasSentMessagesToRecipient,
mode != .fromConversationView {
actionSheet.addAction(ActionSheetAction(title: CommonStrings.sendMessage,
accessibilityIdentifier: "payments.settings.send_message",
style: .default) { _ in
fromViewController.dismiss(animated: true) {
SignalApp.shared().presentConversation(for: recipientAddress, action: .compose, animated: true)
}
})
}
actionSheet.addAction(OWSActionSheets.okayAction)
fromViewController.presentActionSheet(actionSheet)
return
}
let recipientHasPaymentsEnabled = databaseStorage.read { transaction in
Self.paymentsHelper.arePaymentsEnabled(for: recipientAddress, transaction: transaction)
}
if recipientHasPaymentsEnabled {
presentAfterRecipientCheck(presentationMode: presentationMode,
delegate: delegate,
recipientAddress: recipientAddress,
paymentRequestModel: paymentRequestModel,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: mode)
} else {
// Check whether recipient can receive payments.
ModalActivityIndicatorViewController.presentAsInvisible(fromViewController: fromViewController) { modalActivityIndicator in
firstly(on: .global()) {
ProfileFetcherJob.fetchProfilePromise(address: recipientAddress, ignoreThrottling: true)
}.done { (_) in
AssertIsOnMainThread()
modalActivityIndicator.dismiss {
Self.presentAfterRecipientCheck(presentationMode: presentationMode,
delegate: delegate,
recipientAddress: recipientAddress,
paymentRequestModel: paymentRequestModel,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: mode)
}
}.catch { error in
AssertIsOnMainThread()
owsFailDebug("Error: \(error)")
modalActivityIndicator.dismiss {
AssertIsOnMainThread()
Self.showRecipientNotEnabledAlert()
}
}
}
}
}
private static func presentAfterRecipientCheck(presentationMode: PresentationMode,
delegate: SendPaymentViewDelegate,
recipientAddress: SignalServiceAddress,
paymentRequestModel: TSPaymentRequestModel?,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool,
mode: SendPaymentMode) {
let recipientHasPaymentsEnabled = databaseStorage.read { transaction in
Self.paymentsHelper.arePaymentsEnabled(for: recipientAddress, transaction: transaction)
}
guard recipientHasPaymentsEnabled else {
showRecipientNotEnabledAlert()
return
}
let recipient: SendPaymentRecipientImpl = .address(address: recipientAddress)
let view = SendPaymentViewController(recipient: recipient,
paymentRequestModel: paymentRequestModel,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: mode)
view.delegate = delegate
switch presentationMode {
case .fromConversationView(let fromViewController):
let navigationController = OWSNavigationController(rootViewController: view)
fromViewController.presentFormSheet(navigationController, animated: true)
case .inNavigationController(let navigationController):
navigationController.pushViewController(view, animated: true)
}
}
public static func showRecipientNotEnabledAlert() {
OWSActionSheets.showActionSheet(title: NSLocalizedString("PAYMENTS_RECIPIENT_PAYMENTS_NOT_ENABLED_TITLE",
comment: "Title for error alert indicating that a given user cannot receive payments because they have not enabled payments."),
message: NSLocalizedString("PAYMENTS_RECIPIENT_PAYMENTS_NOT_ENABLED_MESSAGE",
comment: "Message for error alert indicating that a given user cannot receive payments because they have not enabled payments."))
}
@objc
public static func presentFromConversationView(_ fromViewController: UIViewController,
delegate: SendPaymentViewDelegate,
recipientAddress: SignalServiceAddress,
paymentRequestModel: TSPaymentRequestModel?,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool) {
present(fromViewController: fromViewController,
presentationMode: .fromConversationView(fromViewController: fromViewController),
delegate: delegate,
recipientAddress: recipientAddress,
paymentRequestModel: paymentRequestModel,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: .fromConversationView)
}
@objc
public static func present(inNavigationController navigationController: UINavigationController,
delegate: SendPaymentViewDelegate,
recipientAddress: SignalServiceAddress,
paymentRequestModel: TSPaymentRequestModel?,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool,
mode: SendPaymentMode) {
present(fromViewController: navigationController,
presentationMode: .inNavigationController(navigationController: navigationController),
delegate: delegate,
recipientAddress: recipientAddress,
paymentRequestModel: paymentRequestModel,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: mode)
}
open override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Theme.backgroundColor
createSubviews()
updateContents()
helper?.refreshObservedValues()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// For now, the design only allows for portrait layout on non-iPads
if !UIDevice.current.isIPad && CurrentAppContext().interfaceOrientation != .portrait {
UIDevice.current.ows_setOrientation(.portrait)
}
helper?.refreshObservedValues()
}
public override func applyTheme() {
super.applyTheme()
updateContents()
}
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return UIDevice.current.isIPad ? .all : .portrait
}
private func resetContents() {
amounts.reset()
memoMessage = nil
}
private func updateContents() {
AssertIsOnMainThread()
view.backgroundColor = Theme.backgroundColor
navigationItem.title = nil
if mode.isModalRootView {
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done,
target: self,
action: #selector(didTapDismiss),
accessibilityIdentifier: "dismiss")
navigationItem.rightBarButtonItem = nil
} else {
navigationItem.leftBarButtonItem = nil
navigationItem.rightBarButtonItem = nil
}
updateAmountLabels()
updateBalanceLabel()
let swapCurrencyIconSize: CGFloat = 24
let bigAmountLeft = UIView.container()
let bigAmountRight: UIView
if nil != currentCurrencyConversion {
bigAmountRight = UIImageView.withTemplateImageName("payments-toggle-24",
tintColor: .ows_gray45)
bigAmountRight.autoPinToSquareAspectRatio()
bigAmountRight.isUserInteractionEnabled = true
bigAmountRight.addGestureRecognizer(UITapGestureRecognizer(target: self,
action: #selector(didTapSwapCurrency)))
} else {
bigAmountRight = UIView.container()
}
bigAmountLeft.autoSetDimension(.width, toSize: swapCurrencyIconSize)
bigAmountRight.autoSetDimension(.width, toSize: swapCurrencyIconSize)
let bigAmountRow = UIStackView(arrangedSubviews: [bigAmountLeft, bigAmountLabel, bigAmountRight])
bigAmountRow.axis = .horizontal
bigAmountRow.alignment = .center
bigAmountRow.spacing = 8
let memoView: UIView
if let hasMemoView = PaymentsViewUtils.buildMemoLabel(memoMessage: memoMessage) {
memoView = hasMemoView
} else {
let addMemoLabel = UILabel()
addMemoLabel.text = NSLocalizedString("PAYMENTS_NEW_PAYMENT_ADD_MEMO",
comment: "Label for the 'add memo' ui in the 'send payment' UI.")
addMemoLabel.font = .ows_dynamicTypeBodyClamped
addMemoLabel.textColor = Theme.accentBlueColor
memoView = addMemoLabel
}
let memoStack = UIStackView(arrangedSubviews: [memoView])
memoStack.axis = .vertical
memoStack.alignment = .center
memoStack.isLayoutMarginsRelativeArrangement = true
memoStack.layoutMargins = UIEdgeInsets(hMargin: 0, vMargin: 12)
memoStack.isUserInteractionEnabled = true
memoStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapAddMemo)))
let spacerFactory = SpacerFactory()
let keyboardViews = buildKeyboard(spacerFactory: spacerFactory)
let amountButtons = buildAmountButtons()
let smallAmountSpacerFactory = SpacerFactory()
let smallAmountRow = UIStackView(arrangedSubviews: [
smallAmountSpacerFactory.buildHSpacer(),
smallAmountLabel,
currencyConversionInfoView,
smallAmountSpacerFactory.buildHSpacer()
])
smallAmountSpacerFactory.finalizeSpacers()
smallAmountRow.axis = .horizontal
smallAmountRow.alignment = .center
smallAmountRow.spacing = 8
smallAmountRow.isUserInteractionEnabled = true
smallAmountRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapCurrencyConversionInfo)))
var requiredViews = [UIView]()
requiredViews += [
bigAmountRow,
smallAmountRow
]
if isIdentifiedPayment {
requiredViews.append(memoStack)
}
requiredViews += [
amountButtons,
balanceLabel
]
requiredViews += keyboardViews.keyboardRows
for requiredView in requiredViews {
requiredView.setCompressionResistanceVerticalHigh()
requiredView.setContentHuggingHigh()
}
rootStack.removeAllSubviews()
rootStack.addArrangedSubviews([
spacerFactory.buildVSpacer(),
bigAmountRow,
spacerFactory.buildVSpacer(),
smallAmountRow,
spacerFactory.buildVSpacer(),
memoStack,
spacerFactory.buildVSpacer()
] +
keyboardViews.allRows
+ [
spacerFactory.buildVSpacer(),
amountButtons,
spacerFactory.buildVSpacer(),
balanceLabel
])
spacerFactory.finalizeSpacers()
UIView.matchHeightsOfViews(keyboardViews.keyboardRows)
}
struct KeyboardViews {
let allRows: [UIView]
let keyboardRows: [UIView]
}
private func buildKeyboard(spacerFactory: SpacerFactory) -> KeyboardViews {
let keyboardHSpacing: CGFloat = 25
let buttonFont = UIFont.ows_dynamicTypeTitle1Clamped
func buildAmountKeyboardButton(title: String, block: @escaping () -> Void) -> OWSButton {
let button = OWSButton(block: block)
let label = UILabel()
label.text = title
label.font = buttonFont
label.textColor = Theme.primaryTextColor
button.addSubview(label)
label.autoCenterInSuperview()
return button
}
func buildAmountKeyboardButton(imageName: String, block: @escaping () -> Void) -> OWSButton {
let button = OWSButton(imageName: imageName,
tintColor: Theme.primaryTextColor,
block: block)
return button
}
var keyboardRows = [UIView]()
let buildAmountKeyboardRow = { (buttons: [OWSButton]) -> UIView in
let buttons = buttons.map { (button) -> UIView in
button.autoPinToAspectRatio(with: CGSize(width: 1, height: 1))
let buttonSize = buttonFont.lineHeight * 2.0
button.autoSetDimension(.height, toSize: buttonSize)
let downStateColor = (Theme.isDarkThemeEnabled
? UIColor.ows_gray90
: UIColor.ows_gray02)
let downStateImage = UIImage(color: downStateColor,
size: CGSize(width: 1, height: 1))
button.setBackgroundImage(downStateImage, for: .highlighted)
// We clip the button to a circle so that the
// down state is circular.
let buttonClipView = OWSLayerView.circleView()
buttonClipView.addSubview(button)
button.autoPinEdgesToSuperviewEdges()
buttonClipView.clipsToBounds = true
let buttonWrapper = UIView.container()
buttonWrapper.addSubview(buttonClipView)
buttonClipView.autoPinEdge(toSuperviewEdge: .top)
buttonClipView.autoPinEdge(toSuperviewEdge: .bottom)
buttonClipView.autoHCenterInSuperview()
buttonClipView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual)
buttonClipView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual)
return buttonWrapper
}
let rowStack = UIStackView(arrangedSubviews: buttons)
rowStack.axis = .horizontal
rowStack.spacing = keyboardHSpacing
rowStack.distribution = .fillEqually
rowStack.alignment = .fill
keyboardRows.append(rowStack)
return rowStack
}
func buildDecimalButton() -> OWSButton {
if let decimalSeparator = PaymentsConstants.decimalSeparator.nilIfEmpty {
return buildAmountKeyboardButton(title: decimalSeparator) { [weak self] in
self?.keyboardPressedDecimal()
}
} else {
return buildAmountKeyboardButton(imageName: "decimal-32") { [weak self] in
self?.keyboardPressedDecimal()
}
}
}
// Don't localize; use Arabic numeral literals.
//
// TODO: Localize or remove custom keyboard to support payments
// in locales that don't use arabic numerals.
let allRows = [
buildAmountKeyboardRow([
buildAmountKeyboardButton(title: "1") { [weak self] in
self?.keyboardPressedNumeral("1")
},
buildAmountKeyboardButton(title: "2") { [weak self] in
self?.keyboardPressedNumeral("2")
},
buildAmountKeyboardButton(title: "3") { [weak self] in
self?.keyboardPressedNumeral("3")
}
]),
spacerFactory.buildVSpacer(),
buildAmountKeyboardRow([
buildAmountKeyboardButton(title: "4") { [weak self] in
self?.keyboardPressedNumeral("4")
},
buildAmountKeyboardButton(title: "5") { [weak self] in
self?.keyboardPressedNumeral("5")
},
buildAmountKeyboardButton(title: "6") { [weak self] in
self?.keyboardPressedNumeral("6")
}
]),
spacerFactory.buildVSpacer(),
buildAmountKeyboardRow([
buildAmountKeyboardButton(title: "7") { [weak self] in
self?.keyboardPressedNumeral("7")
},
buildAmountKeyboardButton(title: "8") { [weak self] in
self?.keyboardPressedNumeral("8")
},
buildAmountKeyboardButton(title: "9") { [weak self] in
self?.keyboardPressedNumeral("9")
}
]),
spacerFactory.buildVSpacer(),
buildAmountKeyboardRow([
buildDecimalButton(),
buildAmountKeyboardButton(title: "0") { [weak self] in
self?.keyboardPressedNumeral("0")
},
buildAmountKeyboardButton(imageName: "delete-32") { [weak self] in
self?.keyboardPressedBackspace()
}
])
]
return KeyboardViews(allRows: allRows, keyboardRows: keyboardRows)
}
private func buildAmountButtons() -> UIView {
let requestButton = buildBottomButton(title: NSLocalizedString("PAYMENTS_NEW_PAYMENT_REQUEST_BUTTON",
comment: "Label for the 'new payment request' button."),
target: self,
selector: #selector(didTapRequestButton))
let payButton = buildBottomButton(title: NSLocalizedString("PAYMENTS_NEW_PAYMENT_PAY_BUTTON",
comment: "Label for the 'new payment' button."),
target: self,
selector: #selector(didTapPayButton))
return buildBottomButtonStack(FeatureFlags.paymentsRequests
? [requestButton, payButton]
: [payButton])
}
// MARK: -
private func createSubviews() {
rootStack.axis = .vertical
rootStack.alignment = .fill
rootStack.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
rootStack.isLayoutMarginsRelativeArrangement = true
view.addSubview(rootStack)
rootStack.autoPinEdge(toSuperviewMargin: .leading)
rootStack.autoPinEdge(toSuperviewMargin: .trailing)
rootStack.autoPin(toTopLayoutGuideOf: self, withInset: 0)
autoPinView(toBottomOfViewControllerOrKeyboard: rootStack, avoidNotch: true)
bigAmountLabel.font = UIFont.ows_dynamicTypeLargeTitle1Clamped.withSize(60)
bigAmountLabel.textAlignment = .center
bigAmountLabel.adjustsFontSizeToFitWidth = true
bigAmountLabel.minimumScaleFactor = 0.25
bigAmountLabel.setContentHuggingVerticalHigh()
bigAmountLabel.setCompressionResistanceVerticalHigh()
smallAmountLabel.font = UIFont.ows_dynamicTypeBody2
smallAmountLabel.textColor = Theme.secondaryTextAndIconColor
smallAmountLabel.textAlignment = .center
smallAmountLabel.setContentHuggingVerticalHigh()
smallAmountLabel.setCompressionResistanceVerticalHigh()
currencyConversionInfoView.setTemplateImageName("info-outline-24",
tintColor: Theme.secondaryTextAndIconColor)
currencyConversionInfoView.autoSetDimensions(to: .square(16))
currencyConversionInfoView.setCompressionResistanceHigh()
}
private func updateAmountLabels() {
let isZero = amount.inputString.isZero
func hideConversionLabelOrShowWarning() {
let shouldHaveValidValue = (!isZero && currentCurrencyConversion != nil)
smallAmountLabel.text = (shouldHaveValidValue
? NSLocalizedString("PAYMENTS_NEW_PAYMENT_INVALID_AMOUNT",
comment: "Label for the 'invalid amount' button.")
: " ")
smallAmountLabel.textColor = UIColor.ows_accentRed
currencyConversionInfoView.tintColor = .clear
}
func enableSmallLabel(_ text: String) {
smallAmountLabel.text = text
smallAmountLabel.textColor = Theme.secondaryTextAndIconColor
currencyConversionInfoView.tintColor = Theme.secondaryTextAndIconColor
}
bigAmountLabel.attributedText = amount.formatAsKeyboardInputAttributed(withSpace: false)
switch amount {
case .mobileCoin:
if let otherCurrencyAmount = self.otherCurrencyAmount,
let currencyConversion = otherCurrencyAmount.currencyConversion {
let formattedAmount = otherCurrencyAmount.formatForDisplay(withSpace: true).string
enableSmallLabel(Self.formatWithConversionFreshness(formattedAmount: formattedAmount,
currencyConversion: currencyConversion,
isZero: isZero))
} else if let currencyConversion = currentCurrencyConversion,
let fiatCurrencyAmount = currencyConversion.convertToFiatCurrency(paymentAmount: parsedPaymentAmount),
let fiatString = PaymentsFormat.attributedFormat(fiatCurrencyAmount: fiatCurrencyAmount,
currencyCode: currencyConversion.currencyCode,
withSpace: true) {
enableSmallLabel(Self.formatWithConversionFreshness(formattedAmount: fiatString.string,
currencyConversion: currencyConversion,
isZero: isZero))
} else {
hideConversionLabelOrShowWarning()
}
case .fiatCurrency(_, let currencyConversion):
if let otherCurrencyAmount = self.otherCurrencyAmount {
let formattedAmount = otherCurrencyAmount.formatForDisplay(withSpace: true).string
enableSmallLabel(Self.formatWithConversionFreshness(formattedAmount: formattedAmount,
currencyConversion: currencyConversion,
isZero: isZero))
} else {
let paymentAmount = currencyConversion.convertFromFiatCurrencyToMOB(amount.asDouble)
let formattedAmount = PaymentsFormat.attributedFormat(paymentAmount: paymentAmount,
isShortForm: false,
withSpace: true).string
enableSmallLabel(Self.formatWithConversionFreshness(formattedAmount: formattedAmount,
currencyConversion: currencyConversion,
isZero: isZero))
}
}
}
static func formatWithConversionFreshness(formattedAmount: String,
currencyConversion: CurrencyConversionInfo,
isZero: Bool) -> String {
guard !isZero else {
return formattedAmount
}
let formattedFreshness = DateUtil.formatDate(asTime: currencyConversion.conversionDate)
let conversionFormat = NSLocalizedString("PAYMENTS_CURRENCY_CONVERSION_FRESHNESS_FORMAT",
comment: "Format for indicator of a payment amount converted to fiat currency with the freshness of the conversion rate. Embeds: {{ %1$@ the payment amount, %2$@ the freshness of the currency conversion rate }}.")
return String(format: conversionFormat, formattedAmount, formattedFreshness)
}
private func updateBalanceLabel() {
guard let helper = helper else {
Logger.verbose("Missing helper.")
return
}
helper.updateBalanceLabel(balanceLabel)
}
private func showInvalidAmountAlert() {
let errorMessage = NSLocalizedString("PAYMENTS_NEW_PAYMENT_INVALID_AMOUNT",
comment: "Label for the 'invalid amount' button.")
OWSActionSheets.showErrorAlert(message: errorMessage)
}
// MARK: - Events
@objc
func didTapDismiss() {
dismiss(animated: true, completion: nil)
}
@objc
func didTapAddMemo() {
let view = SendPaymentMemoViewController(memoMessage: self.memoMessage)
view.delegate = self
navigationController?.pushViewController(view, animated: true)
}
private func updateAmount(_ amount: Amount) -> Amount {
guard let currencyConversion = self.currentCurrencyConversion else {
return amount
}
switch amount {
case .mobileCoin:
return amount
case .fiatCurrency(let inputString, _):
return .fiatCurrency(inputString: inputString, currencyConversion: currencyConversion)
}
}
@objc
func didTapSwapCurrency() {
// If users repeatedly swap input currency, we don't want the
// values to drift due to rounding errors. So we keep around
// the "other" currency amount and use it to swap if no changes
// have been made since the last switch.
if let otherCurrencyAmount = otherCurrencyAmount {
amounts.set(currentAmount: updateAmount(otherCurrencyAmount),
otherCurrencyAmount: updateAmount(self.amount))
return
}
switch amount {
case .mobileCoin:
if let currencyConversion = currentCurrencyConversion,
let fiatCurrencyAmount = currencyConversion.convertToFiatCurrency(paymentAmount: parsedPaymentAmount),
let fiatString = PaymentsFormat.formatAsDoubleString(fiatCurrencyAmount) {
// Store the otherCurrencyAmount.
amounts.set(currentAmount: .fiatCurrency(inputString: InputString.parseString(fiatString, isFiat: true),
currencyConversion: currencyConversion),
otherCurrencyAmount: self.amount)
} else {
owsFailDebug("Could not switch to fiat currency.")
resetContents()
}
case .fiatCurrency(_, let currencyConversion):
let paymentAmount = currencyConversion.convertFromFiatCurrencyToMOB(amount.asDouble)
if let mobString = PaymentsFormat.formatAsDoubleString(picoMob: paymentAmount.picoMob) {
// Store the otherCurrencyAmount.
amounts.set(currentAmount: .mobileCoin(inputString: InputString.parseString(mobString,
isFiat: false),
exactAmount: nil),
otherCurrencyAmount: self.amount)
} else {
owsFailDebug("Could not switch from fiat currency.")
resetContents()
}
}
}
@objc
func didTapRequestButton(_ sender: UIButton) {
// TODO: Add support for requests.
// guard let parsedAmount = parsedAmount,
// parsedAmount > 0 else {
// showInvalidAmountAlert()
// return
// }
// let paymentAmount = TSPaymentAmount(currency: .mobileCoin, picoMob: parsedAmount)
// // Snapshot the conversion rate.
// let currencyConversion = self.currentCurrencyConversion
// currentStep = .confirmRequest(paymentAmount: paymentAmount, currencyConversion: currencyConversion)
}
// MARK: -
private static let keyValueStore = SDSKeyValueStore(collection: "SendPaymentView")
private static let wasLastPaymentInFiatKey = "wasLastPaymentInFiat"
private static var wasLastPaymentInFiat: Bool {
Self.databaseStorage.read { transaction in
Self.keyValueStore.getBool(Self.wasLastPaymentInFiatKey,
defaultValue: false,
transaction: transaction)
}
}
private func setWasLastPaymentInFiat(_ value: Bool) {
Self.databaseStorage.write { transaction in
Self.keyValueStore.setBool(value,
key: Self.wasLastPaymentInFiatKey,
transaction: transaction)
}
}
// MARK: -
private var actionSheet: SendPaymentCompletionActionSheet?
@objc
func didTapPayButton(_ sender: UIButton) {
let paymentAmount = parsedPaymentAmount
guard paymentAmount.picoMob > 0 else {
showInvalidAmountAlert()
return
}
setWasLastPaymentInFiat(amounts.currentAmount.isFiat)
getEstimatedFeeAndSubmit(paymentAmount: paymentAmount)
}
private func getEstimatedFeeAndSubmit(paymentAmount: TSPaymentAmount) {
ModalActivityIndicatorViewController.presentAsInvisible(fromViewController: self) { modalActivityIndicator in
firstly {
Self.paymentsSwift.getEstimatedFee(forPaymentAmount: paymentAmount)
}.done { (estimatedFeeAmount: TSPaymentAmount) in
AssertIsOnMainThread()
modalActivityIndicator.dismiss {
self.tryToShowPaymentCompletionUI(paymentAmount: paymentAmount,
estimatedFeeAmount: estimatedFeeAmount)
}
}.catch { error in
AssertIsOnMainThread()
if case PaymentsError.insufficientFunds = error {
Logger.warn("Error: \(error)")
} else {
owsFailDebugUnlessMCNetworkFailure(error)
}
modalActivityIndicator.dismiss {
AssertIsOnMainThread()
OWSActionSheets.showErrorAlert(
message: SendPaymentCompletionActionSheet.formatPaymentFailure(error,
withErrorPrefix: false)
)
}
}
}
}
private func tryToShowPaymentCompletionUI(paymentAmount: TSPaymentAmount,
estimatedFeeAmount: TSPaymentAmount) {
guard paymentAmount.isValidAmount(canBeEmpty: false),
estimatedFeeAmount.isValidAmount(canBeEmpty: false) else {
showInvalidAmountAlert()
return
}
let totalAmount = paymentAmount.plus(estimatedFeeAmount)
guard let paymentBalance = paymentsSwift.currentPaymentBalance else {
OWSActionSheets.showErrorAlert(message: NSLocalizedString("SETTINGS_PAYMENTS_CANNOT_SEND_PAYMENT_NO_BALANCE",
comment: "Error message indicating that a payment could not be sent because the current balance is unavailable."))
return
}
guard paymentBalance.amount.picoMob >= totalAmount.picoMob else {
showInsufficientBalanceUI(paymentBalance: paymentBalance)
return
}
showPaymentCompletionUI(paymentAmount: paymentAmount,
estimatedFeeAmount: estimatedFeeAmount)
}
private func showInsufficientBalanceUI(paymentBalance: PaymentBalance) {
let messageFormat = NSLocalizedString("SETTINGS_PAYMENTS_PAYMENT_INSUFFICIENT_BALANCE_ALERT_MESSAGE_FORMAT",
comment: "Message for the 'insufficient balance for payment' alert. Embeds: {{ The current payments balance }}.")
let message = String(format: messageFormat, PaymentsFormat.format(paymentAmount: paymentBalance.amount,
isShortForm: false,
withCurrencyCode: true,
withSpace: true))
let actionSheet = ActionSheetController(title: NSLocalizedString("SETTINGS_PAYMENTS_PAYMENT_INSUFFICIENT_BALANCE_ALERT_TITLE",
comment: "Title for the 'insufficient balance for payment' alert."),
message: message)
// There's no point doing a "transfer in" transaction in order to
// enable a "transfer out".
if mode != .fromTransferOutFlow {
actionSheet.addAction(ActionSheetAction(title: NSLocalizedString("SETTINGS_PAYMENTS_PAYMENT_ADD_MONEY",
comment: "Label for the 'add money' button in the 'send payment' UI."),
accessibilityIdentifier: "payments.settings.add_money",
style: .default) { [weak self] _ in
self?.didTapAddMoneyButton()
})
}
actionSheet.addAction(OWSActionSheets.cancelAction)
presentActionSheet(actionSheet)
}
private func didTapAddMoneyButton() {
switch mode {
case .fromConversationView:
dismiss(animated: true) {
guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
owsFailDebug("could not identify frontmostViewController")
return
}
frontmostViewController.navigationController?.popToRootViewController(animated: true)
SignalApp.shared().showAppSettings(mode: .paymentsTransferIn)
}
case .fromPaymentSettings:
let paymentsTransferIn = PaymentsTransferInViewController()
navigationController?.pushViewController(paymentsTransferIn, animated: true)
case .fromTransferOutFlow:
owsFailDebug("Unexpected interaction.")
dismiss(animated: true) {
guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
owsFailDebug("could not identify frontmostViewController")
return
}
guard let navigationController = frontmostViewController.navigationController else {
owsFailDebug("Missing navigationController.")
return
}
let paymentsTransferIn = PaymentsTransferInViewController()
navigationController.pushViewController(paymentsTransferIn, animated: true)
}
}
}
private func showPaymentCompletionUI(paymentAmount: TSPaymentAmount,
estimatedFeeAmount: TSPaymentAmount) {
// Snapshot the conversion rate.
let currencyConversion = self.currentCurrencyConversion
Logger.verbose("paymentAmount: \(paymentAmount)")
Logger.verbose("estimatedFeeAmount: \(estimatedFeeAmount)")
let paymentInfo = PaymentInfo(recipient: recipient,
paymentAmount: paymentAmount,
estimatedFeeAmount: estimatedFeeAmount,
currencyConversion: currencyConversion,
paymentRequestModel: paymentRequestModel,
memoMessage: memoMessage,
isOutgoingTransfer: isOutgoingTransfer)
let actionSheet = SendPaymentCompletionActionSheet(mode: .payment(paymentInfo: paymentInfo),
delegate: self)
self.actionSheet = actionSheet
actionSheet.present(fromViewController: self)
}
private static func showEnablePaymentsActionSheet() {
guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
owsFailDebug("could not identify frontmostViewController")
return
}
let title = NSLocalizedString("SETTINGS_PAYMENTS_NOT_ENABLED_ALERT_TITLE",
comment: "Title for the 'payments not enabled' alert.")
let message = NSLocalizedString("SETTINGS_PAYMENTS_NOT_ENABLED_ALERT_MESSAGE",
comment: "Message for the 'payments not enabled' alert.")
let actionSheet = ActionSheetController(title: title,
message: message)
actionSheet.addAction(ActionSheetAction(title: NSLocalizedString("SETTINGS_PAYMENTS_ENABLE_ACTION",
comment: "Label for the 'enable payments' button in the 'payments not enabled' alert."),
accessibilityIdentifier: "payments.send.enable",
style: .default) { _ in
Self.didTapEnablePaymentsButton()
})
actionSheet.addAction(OWSActionSheets.cancelAction)
frontmostViewController.presentActionSheet(actionSheet)
}
private static func showNotRegisteredActionSheet() {
guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
owsFailDebug("could not identify frontmostViewController")
return
}
let title = NSLocalizedString("SETTINGS_PAYMENTS_NOT_REGISTERED_ALERT_TITLE",
comment: "Title for the 'payments not registered' alert.")
let message = NSLocalizedString("SETTINGS_PAYMENTS_NOT_REGISTERED_ALERT_MESSAGE",
comment: "Message for the 'payments not registered' alert.")
let actionSheet = ActionSheetController(title: title, message: message)
actionSheet.addAction(OWSActionSheets.okayAction)
frontmostViewController.presentActionSheet(actionSheet)
}
private static func didTapEnablePaymentsButton() {
guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
owsFailDebug("could not identify frontmostViewController")
return
}
frontmostViewController.navigationController?.popToRootViewController(animated: true)
SignalApp.shared().showAppSettings(mode: .payments)
}
@objc
private func didTapCurrencyConversionInfo() {
PaymentsSettingsViewController.showCurrencyConversionInfoAlert(fromViewController: self)
}
}
// MARK: -
extension SendPaymentViewController: SendPaymentMemoViewDelegate {
public func didChangeMemo(memoMessage: String?) {
self.memoMessage = memoMessage?.nilIfEmpty
updateContents()
}
}
// MARK: - Payment Keyboard
fileprivate extension SendPaymentViewController {
private func keyboardPressedNumeral(_ numeralString: String) {
let inputString = amount.inputString.append(.digit(digit: numeralString))
updateAmountString(inputString)
}
private func keyboardPressedDecimal() {
let inputString = amount.inputString.append(.decimal)
updateAmountString(inputString)
}
private func keyboardPressedBackspace() {
let inputString = amount.inputString.removeLastChar()
updateAmountString(inputString)
}
private func updateAmountString(_ inputString: InputString) {
switch amount {
case .mobileCoin:
amounts.set(currentAmount: .mobileCoin(inputString: inputString,
exactAmount: nil),
otherCurrencyAmount: nil)
case .fiatCurrency(_, let oldCurrencyConversion):
let newCurrencyConversion = self.currentCurrencyConversion ?? oldCurrencyConversion
amounts.set(currentAmount: .fiatCurrency(inputString: inputString,
currencyConversion: newCurrencyConversion),
otherCurrencyAmount: nil)
}
}
private var parsedPaymentAmount: TSPaymentAmount {
switch amount {
case .mobileCoin(_, let exactAmount):
if let exactAmount = exactAmount {
return exactAmount
}
let picoMob = PaymentsConstants.convertMobToPicoMob(amount.asDouble)
return TSPaymentAmount(currency: .mobileCoin, picoMob: picoMob)
case .fiatCurrency(_, let currencyConversion):
return currencyConversion.convertFromFiatCurrencyToMOB(amount.asDouble)
}
}
}
// MARK: -
extension SendPaymentViewController: SendPaymentHelperDelegate {
@objc
public func balanceDidChange() {
updateBalanceLabel()
}
@objc
public func currencyConversionDidChange() {
guard isViewLoaded else {
return
}
guard nil != currentCurrencyConversion else {
Logger.warn("Currency conversion unavailable.")
resetContents()
return
}
if let otherCurrencyAmount = otherCurrencyAmount {
amounts.set(currentAmount: updateAmount(self.amount),
otherCurrencyAmount: updateAmount(otherCurrencyAmount))
} else {
amounts.set(currentAmount: updateAmount(self.amount),
otherCurrencyAmount: nil)
}
updateAmountLabels()
updateBalanceLabel()
}
}
// MARK: -
extension SendPaymentViewController: SendPaymentCompletionDelegate {
public func didSendPayment() {
let delegate = self.delegate
self.dismiss(animated: true) {
delegate?.didSendPayment()
}
}
}
// MARK: - Amount
private enum Amount {
// inputString should be a raw double strings: e.g. 123456.789.
// It should not be formatted: e.g. 123,456.789
case mobileCoin(inputString: InputString, exactAmount: TSPaymentAmount?)
case fiatCurrency(inputString: InputString, currencyConversion: CurrencyConversionInfo)
var isFiat: Bool {
switch self {
case .mobileCoin:
return false
case .fiatCurrency:
return true
}
}
var isZero: Bool {
switch self {
case .mobileCoin(let inputString, _):
return inputString.isZero
case .fiatCurrency(let inputString, _):
return inputString.isZero
}
}
var inputString: InputString {
switch self {
case .mobileCoin(let inputString, _):
return inputString
case .fiatCurrency(let inputString, _):
return inputString
}
}
var currencyConversion: CurrencyConversionInfo? {
switch self {
case .mobileCoin:
return nil
case .fiatCurrency(_, let currencyConversion):
return currencyConversion
}
}
var asDouble: Double {
inputString.asDouble
}
var formatForDisplay: String {
switch self {
case .mobileCoin:
guard let mobString = PaymentsFormat.format(mob: asDouble,
isShortForm: false) else {
owsFailDebug("Couldn't format MOB string: \(inputString.asString(formatMode: .parsing))")
return inputString.asString(formatMode: .display)
}
return mobString
case .fiatCurrency:
guard let fiatString = PaymentsFormat.format(fiatCurrencyAmount: asDouble,
minimumFractionDigits: 0) else {
owsFailDebug("Couldn't format fiat string: \(inputString.asString(formatMode: .parsing))")
return inputString.asString(formatMode: .display)
}
return fiatString
}
}
var formatAsKeyboardInput: String {
inputString.formatAsKeyboardInput
}
func formatForDisplay(withSpace: Bool) -> NSAttributedString {
switch self {
case .mobileCoin:
return PaymentsFormat.attributedFormat(mobileCoinString: formatForDisplay,
withSpace: withSpace)
case .fiatCurrency(_, let currencyConversion):
return PaymentsFormat.attributedFormat(currencyString: formatForDisplay,
currencyCode: currencyConversion.currencyCode,
withSpace: withSpace)
}
}
func formatAsKeyboardInputAttributed(withSpace: Bool) -> NSAttributedString {
switch self {
case .mobileCoin:
return PaymentsFormat.attributedFormat(mobileCoinString: formatAsKeyboardInput,
withSpace: withSpace)
case .fiatCurrency(_, let currencyConversion):
return PaymentsFormat.attributedFormat(currencyString: formatAsKeyboardInput,
currencyCode: currencyConversion.currencyCode,
withSpace: withSpace)
}
}
}
// MARK: -
private protocol AmountsDelegate: AnyObject {
func amountDidChange(oldValue: Amount, newValue: Amount)
}
// MARK: -
private class Amounts: Dependencies {
weak var delegate: AmountsDelegate?
public static var defaultMCAmount: Amount {
.mobileCoin(inputString: InputString.defaultString(isFiat: false),
exactAmount: nil)
}
public static var defaultFiatAmount: Amount? {
let currentCurrencyCode = Self.paymentsCurrencies.currentCurrencyCode
guard let currencyConversion = Self.paymentsCurrenciesSwift.conversionInfo(forCurrencyCode: currentCurrencyCode) else {
return nil
}
return .fiatCurrency(inputString: InputString.defaultString(isFiat: true),
currencyConversion: currencyConversion)
}
fileprivate private(set) var currentAmount: Amount = Amounts.defaultMCAmount
fileprivate private(set) var otherCurrencyAmount: Amount?
func set(currentAmount: Amount, otherCurrencyAmount: Amount?) {
let oldValue = self.currentAmount
self.currentAmount = currentAmount
self.otherCurrencyAmount = otherCurrencyAmount
delegate?.amountDidChange(oldValue: oldValue, newValue: currentAmount)
}
func reset() {
set(currentAmount: Self.defaultMCAmount, otherCurrencyAmount: nil)
}
}
// MARK: -
extension SendPaymentViewController: AmountsDelegate {
fileprivate func amountDidChange(oldValue: Amount, newValue: Amount) {
guard isViewLoaded else {
return
}
if oldValue.isFiat != newValue.isFiat {
updateContents()
} else {
updateAmountLabels()
}
}
}
// MARK: -
private enum FormatMode {
case display
case parsing
}
// MARK: -
private enum InputChar: Equatable {
case digit(digit: String)
case decimal
func asString(formatMode: FormatMode) -> String {
switch self {
case .digit(let digit):
return digit
case .decimal:
switch formatMode {
case .display:
return PaymentsConstants.decimalSeparator
case .parsing:
return "."
}
}
}
static func isDigit(_ value: String) -> Bool {
"0123456789".contains(value)
}
}
// MARK: -
private struct InputString: Equatable {
let chars: [InputChar]
let isFiat: Bool
init(_ chars: [InputChar], isFiat: Bool) {
self.chars = chars
self.isFiat = isFiat
}
static func forDouble(_ value: Double, isFiat: Bool) -> InputString {
guard let stringValue = PaymentsFormat.formatAsDoubleString(value) else {
owsFailDebug("Couldn't format double: \(value)")
return Self.defaultString(isFiat: isFiat)
}
return parseString(stringValue, isFiat: isFiat)
}
static func parseString(_ stringValue: String, isFiat: Bool) -> InputString {
var result = InputString.defaultString(isFiat: isFiat)
for char in stringValue {
let charString = String(char)
if charString == InputChar.decimal.asString(formatMode: .parsing) {
result = result.append(InputChar.decimal)
} else if InputChar.isDigit(charString) {
result = result.append(InputChar.digit(digit: charString))
} else {
owsFailDebug("Ignoring invalid character: \(charString)")
}
}
return result
}
static var defaultChar: InputChar { .digit(digit: "0") }
static func defaultString(isFiat: Bool) -> InputString {
InputString([defaultChar], isFiat: isFiat)
}
func append(_ char: InputChar) -> InputString {
let result: InputString = {
switch char {
case .digit:
// Avoid leading zeroes.
//
// "00" should be "0"
// "01" should be "1"
if self == Self.defaultString(isFiat: isFiat) {
return InputString([char], isFiat: isFiat)
} else {
return InputString(chars + [char], isFiat: isFiat)
}
case .decimal:
if hasDecimal {
// Don't allow two decimals.
return self
} else {
return InputString(chars + [char], isFiat: isFiat)
}
}
}()
Logger.verbose("Before: \(self.asCharString) -> \(self.asString(formatMode: .parsing)), \(self.digitCountBeforeDecimal), \(self.digitCountAfterDecimal), \(result.asDouble)")
Logger.verbose("Considering: \(result.asCharString) -> \(result.asString(formatMode: .parsing)), \(result.digitCountBeforeDecimal), \(result.digitCountAfterDecimal), \(result.asDouble)")
guard result.isValid else {
Logger.warn("Invalid result: \(self.asString(formatMode: .parsing)) -> \(result.asString(formatMode: .parsing))")
return self
}
return result
}
func removeLastChar() -> InputString {
if chars.count > 1 {
return InputString(Array(chars.prefix(chars.count - 1)),
isFiat: isFiat)
} else {
return Self.defaultString(isFiat: isFiat)
}
}
var isValid: Bool {
(digitCountBeforeDecimal <= maxDigitsBeforeDecimal &&
digitCountAfterDecimal <= maxDigitsAfterDecimal)
}
var isZero: Bool {
!isNonZero
}
var isNonZero: Bool {
for char in chars {
switch char {
case .digit(let digit):
if digit != "0" {
return true
}
case .decimal:
continue
}
}
return false
}
static func maxDigitsBeforeDecimal(isFiat: Bool) -> UInt {
// Max transaction size: 1 billion MOB.
Logger.verbose("maxMobNonDecimalDigits: \(PaymentsConstants.maxMobNonDecimalDigits)")
return isFiat ? 9 : PaymentsConstants.maxMobNonDecimalDigits
}
static func maxDigitsAfterDecimal(isFiat: Bool) -> UInt {
// picoMob
isFiat ? 2 : 12
}
var maxDigitsBeforeDecimal: UInt {
Self.maxDigitsBeforeDecimal(isFiat: isFiat)
}
var maxDigitsAfterDecimal: UInt {
Self.maxDigitsAfterDecimal(isFiat: isFiat)
}
var digitsBeforeDecimal: [String] {
var result = [String]()
for char in chars {
switch char {
case .digit(let digit):
result.append(digit)
case .decimal:
return result
}
}
return result
}
var digitCountBeforeDecimal: Int {
digitsBeforeDecimal.count
}
var digitsAfterDecimal: [String] {
var result = [String]()
var hasPassedDecimal = false
for char in chars {
switch char {
case .digit(let digit):
if hasPassedDecimal {
result.append(digit)
}
case .decimal:
hasPassedDecimal = true
}
}
return result
}
var digitCountAfterDecimal: Int {
digitsAfterDecimal.count
}
var hasDecimal: Bool {
!Array(chars.filter { $0 == .decimal }).isEmpty
}
func asString(formatMode: FormatMode) -> String {
chars.map { $0.asString(formatMode: formatMode) }.joined()
}
var asCharString: String {
"[" + chars.map { $0.asString(formatMode: .parsing) }.joined(separator: ", ") + "]"
}
var asDouble: Double {
Self.parseAsDouble(asString(formatMode: .parsing))
}
private static func parseAsDouble(_ stringValue: String) -> Double {
guard let value = Double(stringValue.ows_stripped()) else {
// inputString should be parseable at all times.
Logger.verbose("stringValue: \(stringValue)")
owsFailDebug("Invalid inputString.")
return 0
}
return value
}
// We need to manually format (more or less) the exact input string
// when redering the "keyboard input" so that every keystroke of
// the custom keyboard updates the "keyboard input" in a WYSIWYG
// fashion, e.g. if the user enters "0.0000000", we need to render
// the exact number of zeros the user has entered.
var formatAsKeyboardInput: String {
let groupingSeparator = PaymentsConstants.groupingSeparator
let decimalSeparator = PaymentsConstants.decimalSeparator
let groupingSize = PaymentsConstants.groupingSize
let shouldUseGroupingSeparatorsAfterDecimal = PaymentsConstants.shouldUseGroupingSeparatorsAfterDecimal
func addGroupingSeparators(digits: [String], afterGroupsOfSize groupSize: Int) -> [String] {
var result = [String]()
for (index, digit) in digits.enumerated() {
if index != 0,
index % groupSize == 0 {
result.append(groupingSeparator)
}
result.append(digit)
}
return result
}
var formattedChars = [String]()
// e.g.:
//
// 1,234,567.890,123,456.
// 0.000,000,001
formattedChars.append(contentsOf: addGroupingSeparators(digits: digitsBeforeDecimal.reversed(),
afterGroupsOfSize: groupingSize).reversed())
if hasDecimal {
formattedChars.append(decimalSeparator)
}
if shouldUseGroupingSeparatorsAfterDecimal {
formattedChars.append(contentsOf: addGroupingSeparators(digits: digitsAfterDecimal,
afterGroupsOfSize: groupingSize))
} else {
formattedChars.append(contentsOf: digitsAfterDecimal)
}
let formatted = formattedChars.joined()
return formatted
}
}
// MARK: -
// This view's contents must adapt to a wide variety of form factors.
// We use vertical spacers of equal height to ensure the layout is
// both responsive and balanced.
class SpacerFactory {
private var hSpacers = [UIView]()
private var vSpacers = [UIView]()
func buildHSpacer() -> UIView {
let spacer = UIView.container()
spacer.setContentHuggingHorizontalLow()
hSpacers.append(spacer)
return spacer
}
func buildVSpacer() -> UIView {
let spacer = UIView.container()
spacer.setContentHuggingVerticalLow()
vSpacers.append(spacer)
return spacer
}
func finalizeSpacers() {
UIView.matchWidthsOfViews(hSpacers)
UIView.matchHeightsOfViews(vSpacers)
}
}