import UIKit @MainActor final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate { private let context: AppContext private let owner: String private let repository: String private let id: Int64? private let saved: (Int64) -> Void private let scrollView = UIScrollView() private let stack = UIStackView() private let titleField = UITextField() private let descriptionView = UITextView() private let closedSwitch = UISwitch() private let dueSwitch = UISwitch() private let duePicker = UIDatePicker() private let spinner = UIActivityIndicatorView(style: .medium) private var task: Task? init( context: AppContext, owner: String, repository: String, id: Int64? = nil, saved: @escaping (Int64) -> Void ) { self.context = context self.owner = owner self.repository = repository self.id = id self.saved = saved super.init(nibName: nil, bundle: nil) title = id == nil ? "New Milestone" : "Edit Milestone" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { task?.cancel() } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGroupedBackground navigationItem.leftBarButtonItem = UIBarButtonItem( barButtonSystemItem: .cancel, target: self, action: #selector(cancel) ) navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) spinner.startAnimating() configureForm() task = Task { await load() } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if id == nil { titleField.becomeFirstResponder() } } private func configureForm() { scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.keyboardDismissMode = .interactive view.addSubview(scrollView) stack.axis = .vertical stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false stack.isHidden = true scrollView.addSubview(stack) titleField.placeholder = "Title" titleField.delegate = self titleField.font = .preferredFont(forTextStyle: .headline) titleField.adjustsFontForContentSizeCategory = true titleField.borderStyle = .roundedRect titleField.autocapitalizationType = .sentences titleField.returnKeyType = .next titleField.accessibilityLabel = "Milestone title" titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged) let descriptionLabel = UILabel() descriptionLabel.text = "Description" descriptionLabel.font = .preferredFont(forTextStyle: .headline) descriptionLabel.adjustsFontForContentSizeCategory = true descriptionView.font = .preferredFont(forTextStyle: .body) descriptionView.adjustsFontForContentSizeCategory = true descriptionView.backgroundColor = .secondarySystemGroupedBackground descriptionView.layer.cornerRadius = 10 descriptionView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) descriptionView.autocapitalizationType = .sentences descriptionView.accessibilityLabel = "Milestone description" let closedLabel = UILabel() closedLabel.text = "Closed" closedLabel.font = .preferredFont(forTextStyle: .body) closedLabel.adjustsFontForContentSizeCategory = true let closedRow = UIStackView(arrangedSubviews: [closedLabel, closedSwitch]) closedRow.alignment = .center closedSwitch.accessibilityLabel = "Closed milestone" let dueLabel = UILabel() dueLabel.text = "Due Date" dueLabel.font = .preferredFont(forTextStyle: .body) dueLabel.adjustsFontForContentSizeCategory = true let dueRow = UIStackView(arrangedSubviews: [dueLabel, dueSwitch]) dueRow.alignment = .center dueSwitch.accessibilityLabel = "Set due date" dueSwitch.addTarget(self, action: #selector(dueChanged), for: .valueChanged) duePicker.datePickerMode = .date duePicker.preferredDatePickerStyle = .inline duePicker.isHidden = true stack.addArrangedSubview(titleField) stack.addArrangedSubview(descriptionLabel) stack.addArrangedSubview(descriptionView) stack.addArrangedSubview(closedRow) stack.addArrangedSubview(dueRow) stack.addArrangedSubview(duePicker) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16), stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16), stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32), descriptionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 180), ]) } private func load() async { do { let page = try await context.core.milestoneEditor( owner: owner, repository: repository, id: id ) guard !Task.isCancelled else { return } titleField.text = page.title descriptionView.text = page.description closedSwitch.isOn = page.closed if let timestamp = page.dueDate { dueSwitch.isOn = true duePicker.date = localDate(forUTCTimestamp: timestamp) duePicker.isHidden = false } stack.isHidden = false } catch { if !Task.isCancelled { show(error: error) } } restoreSaveButton() } func textFieldShouldReturn(_ textField: UITextField) -> Bool { descriptionView.becomeFirstResponder() return true } @objc private func dueChanged() { duePicker.isHidden = !dueSwitch.isOn } @objc private func titleChanged() { navigationItem.rightBarButtonItem?.isEnabled = !(titleField.text ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @objc private func save() { view.endEditing(true) navigationItem.leftBarButtonItem?.isEnabled = false navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) spinner.startAnimating() task?.cancel() task = Task { do { let id = try await context.core.saveMilestone( owner: owner, repository: repository, id: id, draft: MilestoneEditorPage( title: titleField.text ?? "", description: descriptionView.text ?? "", dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil, closed: closedSwitch.isOn ) ) guard !Task.isCancelled else { return } saved(id) } catch { if !Task.isCancelled { show(error: error) restoreSaveButton() } } } } private func restoreSaveButton() { spinner.stopAnimating() navigationItem.leftBarButtonItem?.isEnabled = true navigationItem.rightBarButtonItem = UIBarButtonItem( title: "Save", style: .done, target: self, action: #selector(save) ) titleChanged() } @objc private func cancel() { dismiss(animated: true) } }