// LoopFollow // TextFieldWithToolBar.swift import HealthKit import SwiftUI import UIKit public struct TextFieldWithToolBar: UIViewRepresentable { @Binding var quantity: HKQuantity var textColor: UIColor var textAlignment: NSTextAlignment var autocapitalizationType: UITextAutocapitalizationType var autocorrectionType: UITextAutocorrectionType var shouldBecomeFirstResponder: Bool var maxLength: Int? var isDismissible: Bool var textFieldDidBeginEditing: (() -> Void)? var unit: HKUnit var allowDecimalSeparator: Bool var minValue: HKQuantity? var maxValue: HKQuantity? var onValidationError: (String) -> Void public init( quantity: Binding, textColor: UIColor = .label, textAlignment: NSTextAlignment = .right, autocapitalizationType: UITextAutocapitalizationType = .none, autocorrectionType: UITextAutocorrectionType = .no, shouldBecomeFirstResponder: Bool = false, maxLength: Int? = nil, isDismissible: Bool = true, textFieldDidBeginEditing: (() -> Void)? = nil, unit: HKUnit, allowDecimalSeparator: Bool = true, minValue: HKQuantity? = nil, maxValue: HKQuantity? = nil, onValidationError: @escaping (String) -> Void ) { _quantity = quantity self.textColor = textColor self.textAlignment = textAlignment self.autocapitalizationType = autocapitalizationType self.autocorrectionType = autocorrectionType self.shouldBecomeFirstResponder = shouldBecomeFirstResponder self.maxLength = maxLength self.isDismissible = isDismissible self.textFieldDidBeginEditing = textFieldDidBeginEditing self.unit = unit self.allowDecimalSeparator = allowDecimalSeparator self.minValue = minValue self.maxValue = maxValue self.onValidationError = onValidationError } private func formattedPlaceholder(for unit: HKUnit) -> String { let formatter = NumberFormatter() formatter.minimumFractionDigits = unit.preferredFractionDigits formatter.maximumFractionDigits = unit.preferredFractionDigits formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: 0)) ?? "0" } public func makeUIView(context: Context) -> UITextField { let textField = UITextField() context.coordinator.textField = textField textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin) textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidEnd), for: .editingDidEnd) textField.delegate = context.coordinator textField.text = quantity.doubleValue(for: unit) == 0 ? "" : context.coordinator.format(quantity: quantity, for: unit) textField.placeholder = formattedPlaceholder(for: unit) textField.keyboardType = unit.preferredFractionDigits == 0 ? .numberPad : .decimalPad return textField } private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar { let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50)) let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) let doneButton = UIBarButtonItem( image: UIImage(systemName: "keyboard.chevron.compact.down"), style: .done, target: textField, action: #selector(UITextField.resignFirstResponder) ) let clearButton = UIBarButtonItem( image: UIImage(systemName: "trash"), style: .plain, target: context.coordinator, action: #selector(Coordinator.clearText) ) toolbar.items = [clearButton, flexibleSpace, doneButton] toolbar.sizeToFit() return toolbar } public func updateUIView(_ textField: UITextField, context: Context) { if !context.coordinator.isEditing { let newText = quantity.doubleValue(for: unit) == 0 ? "" : context.coordinator.format(quantity: quantity, for: unit) if textField.text != newText { textField.text = newText } } textField.textColor = textColor textField.textAlignment = textAlignment textField.keyboardType = unit.preferredFractionDigits == 0 ? .numberPad : .decimalPad textField.autocapitalizationType = autocapitalizationType textField.autocorrectionType = autocorrectionType if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder { if textField.window != nil, textField.becomeFirstResponder() { context.coordinator.didBecomeFirstResponder = true } } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder { context.coordinator.didBecomeFirstResponder = false } } public func makeCoordinator() -> Coordinator { Coordinator(self, maxLength: maxLength, unit: unit, minValue: minValue, maxValue: maxValue, onValidationError: onValidationError) } public final class Coordinator: NSObject, UITextFieldDelegate { var parent: TextFieldWithToolBar var textField: UITextField? let maxLength: Int? var didBecomeFirstResponder = false var isEditing = false var unit: HKUnit var minValue: HKQuantity? var maxValue: HKQuantity? let onValidationError: (String) -> Void init(_ parent: TextFieldWithToolBar, maxLength: Int?, unit: HKUnit, minValue: HKQuantity?, maxValue: HKQuantity?, onValidationError: @escaping (String) -> Void) { self.parent = parent self.maxLength = maxLength self.unit = unit self.minValue = minValue self.maxValue = maxValue self.onValidationError = onValidationError } @objc fileprivate func clearText() { DispatchQueue.main.async { self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0) self.textField?.text = "" } } @objc fileprivate func editingDidBegin(_ textField: UITextField) { isEditing = true DispatchQueue.main.async { if self.parent.quantity.doubleValue(for: self.unit) == 0 { textField.text = "" } textField.moveCursorToEnd() } } @objc fileprivate func editingDidEnd(_ textField: UITextField) { isEditing = false DispatchQueue.main.async { let decimalSeparator = Locale.current.decimalSeparator ?? "." let text = textField.text?.replacingOccurrences(of: decimalSeparator, with: ".") ?? "" if text.isEmpty { self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0) } else if let number = Double(text) { let quantity = HKQuantity(unit: self.unit, doubleValue: number) if self.isWithinLimits(quantity) { self.parent.quantity = quantity textField.text = self.format(quantity: self.parent.quantity, for: self.unit) } else { let formatter = NumberFormatter() formatter.minimumFractionDigits = self.unit.preferredFractionDigits formatter.maximumFractionDigits = self.unit.preferredFractionDigits let step = pow(10.0, Double(-formatter.maximumFractionDigits)) var message = "Value outside of guardrails: \(text)\n" if let minValue = self.parent.minValue { let minVal = minValue.doubleValue(for: self.unit) let adjustedMin = ceil(minVal / step) * step let minQuantity = HKQuantity(unit: self.unit, doubleValue: adjustedMin) message += "Minimum: \(self.format(quantity: minQuantity, for: self.unit))\n" } if let maxValue = self.parent.maxValue { let maxVal = maxValue.doubleValue(for: self.unit) let adjustedMax = floor(maxVal / step) * step let maxQuantity = HKQuantity(unit: self.unit, doubleValue: adjustedMax) message += "Maximum: \(self.format(quantity: maxQuantity, for: self.unit))" } self.onValidationError(message) self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0) } } else { self.onValidationError("Invalid number format") } } } func format(quantity: HKQuantity, for unit: HKUnit) -> String { let value = quantity.doubleValue(for: unit) let formatter = NumberFormatter() formatter.minimumFractionDigits = unit.preferredFractionDigits formatter.maximumFractionDigits = max(unit.preferredFractionDigits, 3) formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: value)) ?? "" } private func isWithinLimits(_ quantity: HKQuantity) -> Bool { if let minValue = minValue, quantity.doubleValue(for: unit) < minValue.doubleValue(for: unit) { return false } if let maxValue = maxValue, quantity.doubleValue(for: unit) > maxValue.doubleValue(for: unit) { return false } return true } public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let currentText = textField.text ?? "" guard let textRange = Range(range, in: currentText) else { return false } let updatedText = currentText.replacingCharacters(in: textRange, with: string) if let maxLength = maxLength, updatedText.count > maxLength { return false } let decimalSeparator = Locale.current.decimalSeparator ?? "." let sanitizedText = updatedText.replacingOccurrences(of: decimalSeparator, with: ".") if sanitizedText.isEmpty { DispatchQueue.main.async { self.parent.quantity = HKQuantity(unit: self.unit, doubleValue: 0) } return true } else if let number = Double(sanitizedText) { let quantity = HKQuantity(unit: unit, doubleValue: number) if isWithinLimits(quantity) { DispatchQueue.main.async { self.parent.quantity = quantity } } return true } else { return true } } } } extension UITextField { func moveCursorToEnd() { dispatchPrecondition(condition: .onQueue(.main)) let newPosition = endOfDocument selectedTextRange = textRange(from: newPosition, to: newPosition) } } extension UIApplication { func endEditing() { sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } }