Files
Gotcha/ios/Sources/MilestoneEditorViewController.swift
2026-07-31 15:30:45 +02:00

199 lines
7.6 KiB
Swift

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 dueSwitch = UISwitch()
private let duePicker = UIDatePicker()
private let spinner = UIActivityIndicatorView(style: .medium)
private var task: Task<Void, Never>?
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 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(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
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,
title: titleField.text ?? "",
description: descriptionView.text ?? "",
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil
)
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) }
}