341 lines
14 KiB
Swift
341 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 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 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(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
|
|
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
|
|
)
|
|
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) }
|
|
|
|
}
|