Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Donations/OneTimeDonationCustomAmountTextField.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import SignalServiceKit
import UIKit

protocol OneTimeDonationCustomAmountTextFieldDelegate: AnyObject {
    func oneTimeDonationCustomAmountTextFieldStateDidChange(_ textField: OneTimeDonationCustomAmountTextField)
}

class OneTimeDonationCustomAmountTextField: UIView {
    private let placeholderLabel = UILabel()
    private let symbolLabel = UILabel()
    private let textField = UITextField()
    private let stackView = UIStackView()

    weak var delegate: OneTimeDonationCustomAmountTextFieldDelegate?

    @discardableResult
    override func becomeFirstResponder() -> Bool { textField.becomeFirstResponder() }

    @discardableResult
    override func resignFirstResponder() -> Bool { textField.resignFirstResponder() }

    override var canBecomeFirstResponder: Bool { textField.canBecomeFirstResponder }
    override var canResignFirstResponder: Bool { textField.canResignFirstResponder }
    override var isFirstResponder: Bool { textField.isFirstResponder }

    init(currencyCode: Currency.Code) {
        self.currencyCode = currencyCode

        super.init(frame: .zero)

        backgroundColor = DonationViewsUtil.bubbleBackgroundColor

        textField.autocorrectionType = .no
        textField.spellCheckingType = .no
        textField.keyboardType = .decimalPad
        textField.textAlignment = .center
        textField.delegate = self

        symbolLabel.textAlignment = .center
        placeholderLabel.textAlignment = .center

        stackView.axis = .horizontal

        stackView.addArrangedSubview(placeholderLabel)
        stackView.addArrangedSubview(textField)

        addSubview(stackView)
        stackView.autoPinHeightToSuperview()
        stackView.autoMatch(.width, to: .width, of: self, withMultiplier: 1, relation: .lessThanOrEqual)
        stackView.autoHCenterInSuperview()
        stackView.autoSetDimension(.height, toSize: DonationViewsUtil.amountFieldMinHeight, relation: .greaterThanOrEqual)

        updateVisibility()
        setCurrencyCode(currencyCode)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    var text: String? {
        get { textField.text }
        set {
            textField.text = newValue
            updateVisibility()
        }
    }

    var amount: FiatMoney {
        guard
            let string = valueString(for: text),
            let value = Decimal(string: string, locale: Locale.current),
            value.isFinite
        else {
            return FiatMoney(currencyCode: currencyCode, value: 0)
        }
        return FiatMoney(currencyCode: currencyCode, value: value)
    }

    var font: UIFont? {
        get { textField.font }
        set {
            textField.font = newValue
            placeholderLabel.font = newValue
            symbolLabel.font = newValue
        }
    }

    var textColor: UIColor? {
        get { textField.textColor }
        set {
            textField.textColor = newValue
            placeholderLabel.textColor = newValue
            symbolLabel.textColor = newValue
            if #available(iOS 26, *) {
                textField.tintColor = newValue // caret color
            }
        }
    }

    var placeholder: String? {
        get { placeholderLabel.text }
        set { placeholderLabel.text = newValue }
    }

    private var currencyCode: Currency.Code

    func setCurrencyCode(_ currencyCode: Currency.Code) {
        self.currencyCode = currencyCode

        symbolLabel.removeFromSuperview()

        switch Currency.Symbol.for(currencyCode: currencyCode) {
        case .before(let symbol):
            symbolLabel.text = symbol
            stackView.insertArrangedSubview(symbolLabel, at: 0)
        case .after(let symbol):
            symbolLabel.text = symbol
            stackView.addArrangedSubview(symbolLabel)
        case .currencyCode:
            symbolLabel.text = currencyCode + " "
            stackView.insertArrangedSubview(symbolLabel, at: 0)
        }
    }

    func updateVisibility() {
        let shouldShowPlaceholder = text.isEmptyOrNil && !isFirstResponder
        placeholderLabel.isHiddenInStackView = !shouldShowPlaceholder
        symbolLabel.isHiddenInStackView = shouldShowPlaceholder
        textField.isHiddenInStackView = shouldShowPlaceholder
    }
}

extension OneTimeDonationCustomAmountTextField: UITextFieldDelegate {
    func textFieldDidBeginEditing(_ textField: UITextField) {
        updateVisibility()
        delegate?.oneTimeDonationCustomAmountTextFieldStateDidChange(self)
    }

    func textFieldDidEndEditing(_ textField: UITextField) {
        updateVisibility()
    }

    func textField(_ textField: UITextField, shouldChangeCharactersIn editingRange: NSRange, replacementString: String) -> Bool {
        let existingString = textField.text ?? ""

        let newString = (existingString as NSString).replacingCharacters(in: editingRange, with: replacementString)
        if let numberString = self.valueString(for: newString) {
            textField.text = numberString
            // Make a best effort to preserve cursor position
            if
                let newPosition = textField.position(
                    from: textField.beginningOfDocument,
                    offset: editingRange.location + max(0, numberString.count - existingString.count),
                )
            {
                textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition)
            }
        } else {
            textField.text = ""
        }

        updateVisibility()
        delegate?.oneTimeDonationCustomAmountTextFieldStateDidChange(self)

        return false
    }

    /// Converts an arbitrary string into a string representing a valid value
    /// for the current currency. If no valid value is represented, returns nil
    func valueString(for string: String?) -> String? {
        guard let string else { return nil }

        let isZeroDecimalCurrency = DonationUtilities.zeroDecimalCurrencyCodes.contains(currencyCode)
        guard !isZeroDecimalCurrency else { return string.digitsOnly() }

        let decimalSeparator = Locale.current.decimalSeparator ?? "."
        let components = string.components(separatedBy: decimalSeparator).compactMap { $0.digitsOnly().nilIfEmpty }

        guard let integralString = components.first else {
            if string.contains(decimalSeparator) {
                return "0" + decimalSeparator
            } else {
                return nil
            }
        }

        if let decimalString = components.dropFirst().joined().nilIfEmpty {
            return integralString + decimalSeparator + decimalString
        } else if string.starts(with: decimalSeparator) {
            return "0" + decimalSeparator + integralString
        } else if string.contains(decimalSeparator) {
            return integralString + decimalSeparator
        } else {
            return integralString
        }
    }
}