Path: blob/main/Signal/src/ViewControllers/Payments/SendPaymentViewController.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
public import SignalServiceKit
public import SignalUI
public protocol SendPaymentViewDelegate: AnyObject {
func didSendPayment(success: Bool)
}
// MARK: -
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: -
public class SendPaymentViewController: OWSViewController {
private let mode: SendPaymentMode
fileprivate typealias PaymentInfo = SendPaymentInfo
public weak var delegate: SendPaymentViewDelegate?
private let recipient: SendPaymentRecipient
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
}
private var isUsingPresentedStyle: Bool {
return presentingViewController != nil
}
public init(
recipient: SendPaymentRecipient,
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 let 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,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool,
mode: SendPaymentMode,
) {
guard SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled else {
Logger.info("Payments not enabled.")
showEnablePaymentsActionSheet()
return
}
guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
Logger.info("Local user is not registered and ready.")
showNotRegisteredActionSheet()
return
}
var hasProfileKeyForRecipient = false
var hasSentMessagesToRecipient = false
SSKEnvironment.shared.databaseStorageRef.read { transaction in
if SSKEnvironment.shared.profileManagerRef.userProfile(for: recipientAddress, tx: transaction)?.profileKey != nil {
hasProfileKeyForRecipient = true
return
}
guard let thread = TSContactThread.getWithContactAddress(recipientAddress, transaction: transaction) else {
return
}
let interactionFinder = InteractionFinder(threadUniqueId: thread.uniqueId)
if interactionFinder.outgoingMessageCount(transaction: transaction) > 0 {
hasSentMessagesToRecipient = true
}
}
guard hasProfileKeyForRecipient else {
let title = OWSLocalizedString(
"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
? OWSLocalizedString(
"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.",
)
: OWSLocalizedString(
"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 {
switch mode {
case .fromConversationView:
break
case .fromTransferOutFlow:
owsFailDebug("not a valid mode for this method")
case .fromPaymentSettings:
actionSheet.addAction(ActionSheetAction(
title: CommonStrings.sendMessage,
style: .default,
handler: { [weak fromViewController] _ in
guard let fromViewController else { return }
// We want to get back to the app's main interface. This is shown inside
// Payment Settings, which is presented, and is part of the Send Payment
// flow, which is *also* presented.
let rootViewController = fromViewController.presentingViewController?.presentingViewController
owsAssertDebug(rootViewController != nil)
rootViewController?.dismiss(animated: true) {
SignalApp.shared.presentConversationForAddress(recipientAddress, action: .compose, animated: true)
}
},
))
}
}
actionSheet.addAction(OWSActionSheets.okayAction)
fromViewController.presentActionSheet(actionSheet)
return
}
let recipientHasPaymentsEnabled = SSKEnvironment.shared.databaseStorageRef.read { transaction in
SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled(for: recipientAddress, transaction: transaction)
}
if recipientHasPaymentsEnabled {
presentAfterRecipientCheck(
presentationMode: presentationMode,
delegate: delegate,
recipientAddress: recipientAddress,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: mode,
)
} else {
// Check whether recipient can receive payments.
ModalActivityIndicatorViewController.presentAsInvisible(fromViewController: fromViewController) { modalActivityIndicator in
Task { @MainActor in
do {
guard let serviceId = recipientAddress.serviceId else {
throw ProfileRequestError.notFound
}
let profileFetcher = SSKEnvironment.shared.profileFetcherRef
_ = try await profileFetcher.fetchProfile(for: serviceId)
modalActivityIndicator.dismiss {
Self.presentAfterRecipientCheck(
presentationMode: presentationMode,
delegate: delegate,
recipientAddress: recipientAddress,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: mode,
)
}
} catch {
owsFailDebug("Error: \(error)")
modalActivityIndicator.dismiss {
Self.showRecipientNotEnabledAlert(recipientAddress: recipientAddress)
}
}
}
}
}
}
private static func presentAfterRecipientCheck(
presentationMode: PresentationMode,
delegate: SendPaymentViewDelegate,
recipientAddress: SignalServiceAddress,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool,
mode: SendPaymentMode,
) {
let recipientHasPaymentsEnabled = SSKEnvironment.shared.databaseStorageRef.read { transaction in
SSKEnvironment.shared.paymentsHelperRef.arePaymentsEnabled(for: recipientAddress, transaction: transaction)
}
guard recipientHasPaymentsEnabled else {
showRecipientNotEnabledAlert(recipientAddress: recipientAddress)
return
}
let recipient: SendPaymentRecipientImpl = .address(address: recipientAddress)
let view = SendPaymentViewController(
recipient: recipient,
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)
}
}
private static func showRecipientNotEnabledAlert(recipientAddress: SignalServiceAddress) {
let titleFormat = OWSLocalizedString(
"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. Embeds {{ the contact's name }}",
)
let recipientName: String = SSKEnvironment.shared.databaseStorageRef.read { tx in
SSKEnvironment.shared.contactManagerRef.displayName(for: recipientAddress, tx: tx).resolvedValue()
}
let title = String.nonPluralLocalizedStringWithFormat(titleFormat, recipientName)
let actionSheet = ActionSheetController(
title: title,
message: OWSLocalizedString(
"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.",
),
)
let sendAction = ActionSheetAction(
title: OWSLocalizedString(
"PAYMENTS_RECIPIENT_PAYMENTS_NOT_ENABLED_BUTTON",
comment: "The label for the 'send request' button in alerts and action sheets.",
),
style: .default,
) { _ in
sendActivationRequest(recipientAddress: recipientAddress)
}
actionSheet.addAction(sendAction)
actionSheet.addAction(OWSActionSheets.cancelAction)
OWSActionSheets.showActionSheet(actionSheet)
}
private static func sendActivationRequest(recipientAddress: SignalServiceAddress) {
SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
guard
let thread = TSContactThread.getWithContactAddress(
recipientAddress,
transaction: transaction,
)
else {
return
}
let message = OutgoingPaymentActivationRequestMessage(thread: thread, tx: transaction)
// The request message isn't inserted or rendered in chat; thats done with an info message.
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: message,
)
SSKEnvironment.shared.messageSenderJobQueueRef.add(
message: preparedMessage,
transaction: transaction,
)
if let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci {
let infoMessage = TSInfoMessage(
thread: thread,
messageType: .paymentsActivationRequest,
infoMessageUserInfo: [
.paymentActivationRequestSenderAci: localAci.serviceIdString,
],
)
infoMessage.anyInsert(transaction: transaction)
}
}
}
public static func presentFromConversationView(
_ fromViewController: UIViewController,
delegate: SendPaymentViewDelegate,
recipientAddress: SignalServiceAddress,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool,
) {
present(
fromViewController: fromViewController,
presentationMode: .fromConversationView(fromViewController: fromViewController),
delegate: delegate,
recipientAddress: recipientAddress,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: .fromConversationView,
)
}
public static func present(
inNavigationController navigationController: UINavigationController,
delegate: SendPaymentViewDelegate,
recipientAddress: SignalServiceAddress,
initialPaymentAmount: TSPaymentAmount? = nil,
isOutgoingTransfer: Bool,
mode: SendPaymentMode,
) {
present(
fromViewController: navigationController,
presentationMode: .inNavigationController(navigationController: navigationController),
delegate: delegate,
recipientAddress: recipientAddress,
initialPaymentAmount: initialPaymentAmount,
isOutgoingTransfer: isOutgoingTransfer,
mode: mode,
)
}
override open func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = OWSTableViewController2.tableBackgroundColor(isUsingPresentedStyle: isUsingPresentedStyle)
addListeners()
createSubviews()
updateContents()
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
helper?.refreshObservedValues()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// For now, the design only allows for portrait layout on non-iPads
if !UIDevice.current.isIPad, view.window?.windowScene?.interfaceOrientation != .portrait {
UIDevice.current.ows_setOrientation(.portrait)
}
}
override public func themeDidChange() {
super.themeDidChange()
updateContents()
}
private func addListeners() {
NotificationCenter.default.addObserver(
self,
selector: #selector(isPaymentsVersionOutdatedDidChange),
name: PaymentsConstants.isPaymentsVersionOutdatedDidChange,
object: nil,
)
}
@objc
private func isPaymentsVersionOutdatedDidChange() {
guard UIApplication.shared.frontmostViewController == self else { return }
if SSKEnvironment.shared.paymentsHelperRef.isPaymentsVersionOutdated {
OWSActionSheets.showPaymentsOutdatedClientSheet(title: .updateRequired)
}
}
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return UIDevice.current.isIPad ? .all : .portrait
}
private func resetContents() {
amounts.reset()
memoMessage = nil
}
private func updateContents() {
AssertIsOnMainThread()
view.backgroundColor = OWSTableViewController2.tableBackgroundColor(isUsingPresentedStyle: isUsingPresentedStyle)
navigationItem.title = nil
if mode.isModalRootView {
navigationItem.rightBarButtonItem = .doneButton(dismissingFrom: self)
} else {
navigationItem.rightBarButtonItem = nil
}
updateAmountLabels()
updateBalanceLabel()
let swapCurrencyIconSize: CGFloat = 24
let bigAmountLeft = UIView.container()
let bigAmountRight: UIView
if nil != currentCurrencyConversion {
bigAmountRight = UIImageView.withTemplateImageName("transfer", 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 = OWSLocalizedString(
"PAYMENTS_NEW_PAYMENT_ADD_MEMO",
comment: "Label for the 'add memo' ui in the 'send payment' UI.",
)
addMemoLabel.font = .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(),
spacerFactory.buildVSpacer(),
spacerFactory.buildVSpacer(),
bigAmountRow,
smallAmountRow,
spacerFactory.buildVSpacer(),
spacerFactory.buildVSpacer(),
memoStack,
spacerFactory.buildVSpacer(),
spacerFactory.buildVSpacer(),
spacerFactory.buildVSpacer(),
] +
keyboardViews.allRows
+ [
spacerFactory.buildVSpacer(),
spacerFactory.buildVSpacer(),
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 = 32
let buttonFont = UIFont.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)
button.backgroundColor = OWSTableViewController2.cellBackgroundColor(isUsingPresentedStyle: isUsingPresentedStyle)
label.autoCenterInSuperview()
return button
}
func buildAmountKeyboardButton(imageName: String, block: @escaping () -> Void) -> OWSButton {
let button = OWSButton(
imageName: imageName,
tintColor: Theme.primaryTextColor,
block: block,
)
button.backgroundColor = OWSTableViewController2.cellBackgroundColor(isUsingPresentedStyle: isUsingPresentedStyle)
return button
}
var keyboardRows = [UIView]()
let buildAmountKeyboardRow = { (buttons: [OWSButton]) -> UIView in
let buttons = buttons.map { button -> UIView in
let buttonSize = buttonFont.lineHeight * 1.7
button.autoSetDimension(.height, toSize: buttonSize)
let downStateColor = (
Theme.isDarkThemeEnabled
? UIColor.ows_gray90
: UIColor.ows_gray02,
)
let downStateImage = UIImage.image(
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(toSuperviewEdge: .leading)
buttonClipView.autoPinEdge(toSuperviewEdge: .trailing)
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: "backspace-32") { [weak self] in
self?.keyboardPressedBackspace()
},
]),
]
return KeyboardViews(allRows: allRows, keyboardRows: keyboardRows)
}
private func buildAmountButtons() -> UIView {
return buildBottomButtonStack([buildBottomButton(
title: OWSLocalizedString("PAYMENTS_NEW_PAYMENT_PAY_BUTTON", comment: "Label for the 'new payment' button."),
target: self,
selector: #selector(didTapPayButton),
)])
}
// MARK: -
private func createSubviews() {
rootStack.axis = .vertical
rootStack.alignment = .fill
rootStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(rootStack)
NSLayoutConstraint.activate([
rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
rootStack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 20),
rootStack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor, constant: -20),
rootStack.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor, constant: -24),
])
bigAmountLabel.font = UIFont.regularFont(ofSize: 60)
bigAmountLabel.textAlignment = .center
bigAmountLabel.adjustsFontSizeToFitWidth = true
bigAmountLabel.minimumScaleFactor = 0.25
bigAmountLabel.setContentHuggingVerticalHigh()
bigAmountLabel.setCompressionResistanceVerticalHigh()
smallAmountLabel.font = UIFont.dynamicTypeSubheadline
smallAmountLabel.textColor = Theme.secondaryTextAndIconColor
smallAmountLabel.textAlignment = .center
smallAmountLabel.setContentHuggingVerticalHigh()
smallAmountLabel.setCompressionResistanceVerticalHigh()
currencyConversionInfoView.setTemplateImageName("info-compact", 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
? OWSLocalizedString(
"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.formatDateAsTime(currencyConversion.conversionDate)
let conversionFormat = OWSLocalizedString(
"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.nonPluralLocalizedStringWithFormat(conversionFormat, formattedAmount, formattedFreshness)
}
private func updateBalanceLabel() {
guard let helper else {
return
}
helper.updateBalanceLabel(balanceLabel)
}
private func showInvalidAmountAlert() {
let errorMessage = OWSLocalizedString(
"PAYMENTS_NEW_PAYMENT_INVALID_AMOUNT",
comment: "Label for the 'invalid amount' button.",
)
OWSActionSheets.showErrorAlert(message: errorMessage)
}
// MARK: - Events
@objc
private 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
private 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 {
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
private 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 = KeyValueStore(collection: "SendPaymentView")
private static let wasLastPaymentInFiatKey = "wasLastPaymentInFiat"
private static var wasLastPaymentInFiat: Bool {
SSKEnvironment.shared.databaseStorageRef.read { transaction in
Self.keyValueStore.getBool(
Self.wasLastPaymentInFiatKey,
defaultValue: false,
transaction: transaction,
)
}
}
private func setWasLastPaymentInFiat(_ value: Bool) {
SSKEnvironment.shared.databaseStorageRef.write { transaction in
Self.keyValueStore.setBool(
value,
key: Self.wasLastPaymentInFiatKey,
transaction: transaction,
)
}
}
// MARK: -
private var actionSheet: SendPaymentCompletionActionSheet?
@objc
private 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
Promise.wrapAsync {
try await SUIEnvironment.shared.paymentsSwiftRef.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 = SUIEnvironment.shared.paymentsSwiftRef.currentPaymentBalance else {
OWSActionSheets.showErrorAlert(message: OWSLocalizedString(
"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 = OWSLocalizedString(
"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.nonPluralLocalizedStringWithFormat(messageFormat, PaymentsFormat.format(
paymentAmount: paymentBalance.amount,
isShortForm: false,
withCurrencyCode: true,
withSpace: true,
))
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"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: OWSLocalizedString(
"SETTINGS_PAYMENTS_PAYMENT_ADD_MONEY",
comment: "Label for the 'add money' button in the 'send payment' UI.",
),
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
let paymentInfo = PaymentInfo(
recipient: recipient,
paymentAmount: paymentAmount,
estimatedFeeAmount: estimatedFeeAmount,
currencyConversion: currencyConversion,
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 = OWSLocalizedString(
"SETTINGS_PAYMENTS_NOT_ENABLED_ALERT_TITLE",
comment: "Title for the 'payments not enabled' alert.",
)
let message = OWSLocalizedString(
"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: OWSLocalizedString(
"SETTINGS_PAYMENTS_ENABLE_ACTION",
comment: "Label for the 'enable payments' button in the 'payments not enabled' alert.",
),
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 = OWSLocalizedString(
"SETTINGS_PAYMENTS_NOT_REGISTERED_ALERT_TITLE",
comment: "Title for the 'payments not registered' alert.",
)
let message = OWSLocalizedString(
"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
private 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 {
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 {
public func balanceDidChange() {
updateBalanceLabel()
}
public func currencyConversionDidChange() {
guard isViewLoaded else {
return
}
guard nil != currentCurrencyConversion else {
Logger.warn("Currency conversion unavailable.")
resetContents()
return
}
if let 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(success: Bool) {
let delegate = self.delegate
self.dismiss(animated: true) {
delegate?.didSendPayment(success: success)
}
}
}
// 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 {
weak var delegate: AmountsDelegate?
static var defaultMCAmount: Amount {
.mobileCoin(
inputString: InputString.defaultString(isFiat: false),
exactAmount: nil,
)
}
static var defaultFiatAmount: Amount? {
let currentCurrencyCode = SSKEnvironment.shared.paymentsCurrenciesRef.currentCurrencyCode
guard let currencyConversion = SSKEnvironment.shared.paymentsCurrenciesRef.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)
}
}
}()
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.
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.
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)
}
}