Files
Gotcha/ios/Sources/IssueEditorViewController.swift
2026-08-01 15:48:14 +02:00

353 lines
14 KiB
Swift

import Highlighter
import SwiftUI
import UIKit
@MainActor
final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UITextViewDelegate {
private let context: AppContext
private let owner: String
private let repository: String
private let number: Int64?
private let saved: (Int64) -> Void
private let scrollView = UIScrollView()
private let stack = UIStackView()
private let titleField = UITextField()
private let modeControl = UISegmentedControl(items: ["Write", "Preview"])
private let bodyView = UITextView()
private let previewView = UIView()
private let labelsButton = UIButton(type: .system)
private let milestoneButton = UIButton(type: .system)
private let closedSwitch = UISwitch()
private let dueSwitch = UISwitch()
private let duePicker = UIDatePicker()
private let spinner = UIActivityIndicatorView(style: .medium)
private var page: IssueEditorPage?
private var selectedLabels = Set<Int64>()
private var selectedMilestone: Int64?
private var task: Task<Void, Never>?
private var previewController: UIViewController?
private var editorFont: UIFont {
UIFontMetrics(forTextStyle: .body).scaledFont(
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
)
}
init(
context: AppContext,
owner: String,
repository: String,
number: Int64? = nil,
saved: @escaping (Int64) -> Void
) {
self.context = context
self.owner = owner
self.repository = repository
self.number = number
self.saved = saved
super.init(nibName: nil, bundle: nil)
title = number == nil ? "New Issue" : "Edit Issue"
}
@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(
title: "Save",
style: .done,
target: self,
action: #selector(save)
)
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
spinner.startAnimating()
configureForm()
task = Task { await load() }
}
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 = "Issue title"
titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged)
[labelsButton, milestoneButton].forEach { button in
var configuration = UIButton.Configuration.tinted()
configuration.imagePlacement = .leading
configuration.imagePadding = 8
button.configuration = configuration
button.showsMenuAsPrimaryAction = true
button.contentHorizontalAlignment = .leading
}
labelsButton.configuration?.image = context.symbol("tag")
milestoneButton.configuration?.image = context.symbol("flag")
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 issue"
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
modeControl.selectedSegmentIndex = 0
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
bodyView.delegate = self
bodyView.font = editorFont
bodyView.adjustsFontForContentSizeCategory = true
bodyView.backgroundColor = .secondarySystemGroupedBackground
bodyView.layer.cornerRadius = 10
bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
bodyView.autocapitalizationType = .sentences
bodyView.accessibilityLabel = "Issue description in Markdown"
previewView.backgroundColor = .secondarySystemGroupedBackground
previewView.layer.cornerRadius = 10
previewView.accessibilityLabel = "Issue description preview"
previewView.isHidden = true
stack.addArrangedSubview(titleField)
stack.addArrangedSubview(labelsButton)
stack.addArrangedSubview(milestoneButton)
stack.addArrangedSubview(closedRow)
stack.addArrangedSubview(dueRow)
stack.addArrangedSubview(duePicker)
stack.addArrangedSubview(modeControl)
stack.addArrangedSubview(bodyView)
stack.addArrangedSubview(previewView)
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),
bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
])
}
private func load() async {
do {
let page = try await context.core.issueEditor(
owner: owner,
repository: repository,
number: number
)
guard !Task.isCancelled else { return }
self.page = page
titleField.text = page.title
bodyView.text = page.body
closedSwitch.isOn = page.closed
selectedLabels = Set(page.labels.filter(\.selected).map(\.id))
selectedMilestone = page.milestones.first(where: \.selected)?.id
if let timestamp = page.dueDate {
dueSwitch.isOn = true
duePicker.date = localDate(forUTCTimestamp: timestamp)
duePicker.isHidden = false
}
updateLabelsMenu()
updateMilestoneMenu()
highlightBody()
stack.isHidden = false
navigationItem.rightBarButtonItem?.isEnabled = !page.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} catch {
if !Task.isCancelled { show(error: error) }
}
restoreSaveButton()
}
private func updateLabelsMenu() {
guard let page else { return }
labelsButton.configuration?.title = selectedLabels.isEmpty
? "Labels"
: "Labels (\(selectedLabels.count))"
labelsButton.accessibilityValue = selectedLabels.isEmpty
? "None"
: page.labels.filter { selectedLabels.contains($0.id) }.map(\.name).joined(separator: ", ")
let actions = page.labels.map { label in
UIAction(
title: label.name,
attributes: .keepsMenuPresented,
state: selectedLabels.contains(label.id) ? .on : .off
) { [weak self] action in
guard let self else { return }
if self.selectedLabels.remove(label.id) == nil { self.selectedLabels.insert(label.id) }
action.state = self.selectedLabels.contains(label.id) ? .on : .off
self.updateLabelsMenu()
}
}
labelsButton.menu = UIMenu(children: actions.isEmpty
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
: actions)
}
private func updateMilestoneMenu() {
guard let page else { return }
milestoneButton.configuration?.title = page.milestones
.first(where: { $0.id == selectedMilestone })?.title ?? "Milestone"
milestoneButton.accessibilityValue = page.milestones
.first(where: { $0.id == selectedMilestone })?.title ?? "None"
let none = UIAction(title: "No Milestone", state: selectedMilestone == nil ? .on : .off) {
[weak self] _ in
self?.selectedMilestone = nil
self?.updateMilestoneMenu()
}
milestoneButton.menu = UIMenu(options: .singleSelection, children: [none] + page.milestones.map { milestone in
UIAction(
title: milestone.title,
state: milestone.id == selectedMilestone ? .on : .off
) { [weak self] _ in
self?.selectedMilestone = milestone.id
self?.updateMilestoneMenu()
}
})
}
func textViewDidChange(_ textView: UITextView) {
highlightBody()
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
bodyView.becomeFirstResponder()
return true
}
private func highlightBody() {
let source = bodyView.text ?? ""
let selection = bodyView.selectedRange
let highlighter = Highlighter()
highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light")
highlighter?.theme.setCodeFont(editorFont)
bodyView.attributedText = highlighter?.highlight(source, as: "markdown")
?? NSAttributedString(string: source, attributes: [
.font: editorFont,
.foregroundColor: UIColor.label,
])
bodyView.selectedRange = selection
bodyView.typingAttributes = [
.font: editorFont,
.foregroundColor: UIColor.label,
]
}
@objc private func modeChanged() {
let preview = modeControl.selectedSegmentIndex == 1
if preview {
previewController?.willMove(toParent: nil)
previewController?.view.removeFromSuperview()
previewController?.removeFromParent()
let controller = UIHostingController(
rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "")
)
addChild(controller)
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.layer.cornerRadius = 10
controller.view.clipsToBounds = true
previewView.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor),
controller.view.topAnchor.constraint(equalTo: previewView.topAnchor),
controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor),
])
controller.didMove(toParent: self)
previewController = controller
}
bodyView.isHidden = preview
previewView.isHidden = !preview
view.endEditing(true)
}
@objc private func dueChanged() {
duePicker.isHidden = !dueSwitch.isOn
if dueSwitch.isOn, page?.dueDate == nil { duePicker.date = Date() }
}
@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 number = try await context.core.saveIssue(
owner: owner,
repository: repository,
number: number,
title: titleField.text ?? "",
body: bodyView.text ?? "",
labelIds: Array(selectedLabels),
milestoneId: selectedMilestone,
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil,
closed: closedSwitch.isOn
)
guard !Task.isCancelled else { return }
saved(number)
} 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) }
}