TheGuyWhoCantCode123
TheGuyWhoCantCode123

Reputation: 13

How to manage the state of a "Send Code" button in Swift with error handling and countdown timer?

I'm working on a login screen in my iOS app using Swift. The user needs to enter their phone number and press a "Send Code" button to receive a verification code. Here's what I'm trying to achieve with the button's state:

  1. The button should be disabled and greyed out immediately after the user presses it, regardless of any errors.
  2. If there is an error (e.g., the user didn't enter enough digits), the button should be re-enabled after the user dismisses the error alert. The countdown timer should not start in this case.
  3. If the user successfully receives a verification code, the button should remain disabled for 1 minute, during which a countdown timer is displayed.

The reason for disabling the button for 1 minute is to prevent the user from spamming the button and potentially overwhelming the server with requests.

Here is my current implementation of the view controller:

import UIKit
import FirebaseAuth
import FirebaseFunctions

class LoginViewController: BaseViewController, UITextFieldDelegate {
let phoneLabel: UILabel = {
    let label = UILabel()
    label.text = "Enter Phone Number"
    label.font = UIFont.systemFont(ofSize: 24, weight: .medium)
    label.translatesAutoresizingMaskIntoConstraints = false
    return label
}()

let phoneTextField: UITextField = {
    let textField = UITextField()
    textField.placeholder = "Phone number"
    textField.borderStyle = .roundedRect
    textField.keyboardType = .numberPad
    textField.translatesAutoresizingMaskIntoConstraints = false
    return textField
}()

let sendCodeButton: UIButton = {
    let button = UIButton(type: .system)
    button.setTitle("Send code", for: .normal)
    button.layer.cornerRadius = 10
    button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18)
    button.isEnabled = false
    button.addTarget(self, action: #selector(sendCodeButtonTapped), for: .touchUpInside)
    button.translatesAutoresizingMaskIntoConstraints = false
    return button
}()

let countdownLabel: UILabel = {
    let label = UILabel()
    label.text = ""
    label.font = UIFont.systemFont(ofSize: 14)
    label.textAlignment = .left
    label.textColor = .gray
    label.translatesAutoresizingMaskIntoConstraints = false
    label.isHidden = true
    return label
}()

lazy var functions = Functions.functions()
var isCountdownActive = false
var countdownEndTime: Date?

override func viewDidLoad() {
    super.viewDidLoad()
    
    setupUI()
    updateButtonStyles()
    
    let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
    view.addGestureRecognizer(tapGesture)
    
    phoneTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
    
    loadCountdownState()
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    updateSendCodeButtonState()
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle {
        updateButtonStyles()
    }
}

private func setupUI() {
    title = "Log in"
    
    view.addSubview(phoneLabel)
    view.addSubview(phoneTextField)
    view.addSubview(sendCodeButton)
    view.addSubview(countdownLabel)
    view.addSubview(activityIndicator)
    
    NSLayoutConstraint.activate([
        phoneLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
        phoneLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
        phoneLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
        
        phoneTextField.topAnchor.constraint(equalTo: phoneLabel.bottomAnchor, constant: 10),
        phoneTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
        phoneTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
        
        sendCodeButton.topAnchor.constraint(equalTo: phoneTextField.bottomAnchor, constant: 20),
        sendCodeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
        sendCodeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
        sendCodeButton.heightAnchor.constraint(equalToConstant: 50),
        
        countdownLabel.topAnchor.constraint(equalTo: sendCodeButton.bottomAnchor, constant: 10),
        countdownLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
        countdownLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
        
        activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
    ])
    
    phoneTextField.delegate = self
}

private func updateButtonStyles() {
    sendCodeButton.layer.shadowColor = UIColor.black.cgColor
    sendCodeButton.layer.shadowOffset = CGSize(width: 0, height: 2)
    sendCodeButton.layer.shadowOpacity = 0.2
    sendCodeButton.layer.shadowRadius = 4
}

private func updateSendCodeButtonState() {
    let isTextEmpty = phoneTextField.text?.isEmpty ?? true
    let isEnabled = !isTextEmpty && !isCountdownActive
    
    sendCodeButton.isEnabled = isEnabled
    let enabledColor = UIColor { traitCollection in
        switch traitCollection.userInterfaceStyle {
        case .dark:
            return UIColor(hex: "#00CC88")
        default:
            return UIColor(hex: "#00C897")
        }
    }
    
    let disabledColor = UIColor.lightGray
    let titleColor = isEnabled ? UIColor { traitCollection in
        switch traitCollection.userInterfaceStyle {
        case .dark:
            return UIColor(hex: "#0056B3")
        default:
            return UIColor(hex: "#003366")
        }
    } : .darkGray
    
    sendCodeButton.backgroundColor = isEnabled ? enabledColor : disabledColor
    sendCodeButton.setTitleColor(titleColor, for: .normal)
}

@objc private func textFieldDidChange(_ textField: UITextField) {
    updateSendCodeButtonState()
}

@objc private func sendCodeButtonTapped() {
    guard let phoneNumber = phoneTextField.text, phoneNumber.count == 10 else {
        showAlert(title: "Error", message: "Phone number must be exactly 10 digits.")
        sendCodeButton.isEnabled = true
        return
    }
    
    let formattedPhoneNumber = "+1\(phoneNumber)"
    
    sendCodeButton.isEnabled = false
    updateSendCodeButtonState()
    
    activityIndicator.startAnimating()
    
    functions.httpsCallable("checkPhoneNumberExists").call(["phoneNumber": formattedPhoneNumber]) { [weak self] result, error in
        self?.activityIndicator.stopAnimating()
        
        if let error = error {
            self?.showAlert(title: "Error", message: "Error checking phone number: \(error.localizedDescription)")
            self?.sendCodeButton.isEnabled = true
            return
        }
        
        guard let data = result?.data as? [String: Any], let exists = data["exists"] as? Bool else {
            self?.showAlert(title: "Error", message: "Invalid response from server.")
            self?.sendCodeButton.isEnabled = true
            return
        }
        
        if exists {
            self?.sendVerificationCode(to: formattedPhoneNumber)
        } else {
            self?.showAlert(title: "Error", message: "Phone number does not exist. Please sign up first.")
            self?.sendCodeButton.isEnabled = true
        }
    }
}

private func startCountdown(seconds: Int) {
    var remainingSeconds = seconds
    isCountdownActive = true
    countdownEndTime = Date().addingTimeInterval(TimeInterval(seconds))
    UserDefaults.standard.set(countdownEndTime?.timeIntervalSince1970, forKey: "countdownEndTime")
    countdownLabel.text = "Resend code in: \(remainingSeconds)s"
    
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
        remainingSeconds -= 1
        if remainingSeconds > 0 {
            self?.countdownLabel.text = "Resend code in: \(remainingSeconds)s"
        } else {
            timer.invalidate()
            self?.isCountdownActive = false
            self?.countdownEndTime = nil
            UserDefaults.standard.removeObject(forKey: "countdownEndTime")
            self?.updateSendCodeButtonState()
            self?.countdownLabel.isHidden = true
        }
    }
}

private func sendVerificationCode(to phoneNumber: String) {
    PhoneAuthProvider.provider().verifyPhoneNumber(phoneNumber, uiDelegate: nil) { [weak self] verificationID, error in
        if let error = error {
            self?.showAlert(title: "Error", message: "Unable to send code. Please try again.")
            print(error.localizedDescription)
            self?.sendCodeButton.isEnabled = true
            return
        }
        
        UserDefaults.standard.set(verificationID, forKey: "authVerificationID")
        UserDefaults.standard.set(phoneNumber, forKey: "phoneNumber")
        
        self?.countdownLabel.isHidden = false
        self?.startCountdown(seconds: 60)
        
        let verificationVC = LoginPhoneVerificationViewController(phoneNumber: phoneNumber)
        self?.navigationController?.pushViewController(verificationVC, animated: true)
    }
}

func setCustomBackButton() {
    let backButton = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
    navigationItem.backBarButtonItem = backButton
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    let allowedCharacters = CharacterSet.decimalDigits
    let characterSet = CharacterSet(charactersIn: string)
    let currentText = textField.text ?? ""
    let prospectiveText = (currentText as NSString).replacingCharacters(in: range, with: string)
    
    updateSendCodeButtonState()
    
    return allowedCharacters.isSuperset(of: characterSet) && prospectiveText.count <= 10
}

let activityIndicator: UIActivityIndicatorView = {
    let indicator = UIActivityIndicatorView(style: .medium)
    indicator.translatesAutoresizingMaskIntoConstraints = false
    indicator.hidesWhenStopped = true
    return indicator
}()

private func showAlert(title: String, message: String) {
    let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
    alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
    present(alertController, animated: true, completion: nil)
}

@objc func dismissKeyboard() {
    view.endEditing(true)
}

private func loadCountdownState() {
    if let endTimeInterval = UserDefaults.standard.value(forKey: "countdownEndTime") as? TimeInterval {
        let endTime = Date(timeIntervalSince1970: endTimeInterval)
        let remainingTime = endTime.timeIntervalSince(Date())
        
        if remainingTime > 0 {
            isCountdownActive = true
            countdownLabel.isHidden = false
            startCountdown(seconds: Int(remainingTime))
        }
        }
    }
}

Upvotes: 0

Views: 32

Answers (0)

Related Questions