Add milestone creation and editing
This commit is contained in:
@@ -375,9 +375,28 @@ final class MilestonesViewController: RefreshingTableViewController {
|
||||
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 118
|
||||
let addButton = UIBarButtonItem(
|
||||
barButtonSystemItem: .add,
|
||||
target: self,
|
||||
action: #selector(createMilestone)
|
||||
)
|
||||
addButton.accessibilityLabel = "New milestone"
|
||||
navigationItem.rightBarButtonItem = addButton
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
@objc private func createMilestone() {
|
||||
let editor = MilestoneEditorViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
|
||||
}
|
||||
present(UINavigationController(rootViewController: editor), animated: true)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
loadPage(1, refreshing: refreshing)
|
||||
}
|
||||
@@ -476,9 +495,30 @@ final class MilestoneViewController: RefreshingTableViewController {
|
||||
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 118
|
||||
let editButton = UIBarButtonItem(
|
||||
image: context.symbol("pencil"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(editMilestone)
|
||||
)
|
||||
editButton.accessibilityLabel = "Edit milestone"
|
||||
navigationItem.rightBarButtonItem = editButton
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
@objc private func editMilestone() {
|
||||
let editor = MilestoneEditorViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
id: id
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
|
||||
}
|
||||
present(UINavigationController(rootViewController: editor), animated: true)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
loadPage(1, refreshing: refreshing)
|
||||
}
|
||||
|
||||
@@ -154,11 +154,12 @@ final class IssueViewController: MarkdownPageViewController {
|
||||
)
|
||||
addComment.accessibilityLabel = "Add comment"
|
||||
let editIssue = UIBarButtonItem(
|
||||
title: "Edit",
|
||||
image: context.symbol("pencil"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(editIssue)
|
||||
)
|
||||
editIssue.accessibilityLabel = "Edit issue"
|
||||
navigationItem.rightBarButtonItems = [addComment, editIssue]
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
@@ -884,15 +885,15 @@ private func commentViews(
|
||||
var headerViews: [UIView] = [author, UIView()]
|
||||
if comment.canEdit, let editComment {
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = "Edit"
|
||||
configuration.image = UIImage(systemName: "pencil")
|
||||
configuration.imagePadding = 5
|
||||
configuration.contentInsets = .zero
|
||||
let button = UIButton(
|
||||
configuration: configuration,
|
||||
primaryAction: UIAction { _ in editComment(comment) }
|
||||
)
|
||||
button.accessibilityLabel = "Edit comment by \(comment.author)"
|
||||
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
button.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
headerViews.append(button)
|
||||
}
|
||||
let header = UIStackView(arrangedSubviews: headerViews)
|
||||
|
||||
@@ -337,17 +337,4 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI
|
||||
|
||||
@objc private func cancel() { dismiss(animated: true) }
|
||||
|
||||
private func localDate(forUTCTimestamp timestamp: Int64) -> Date {
|
||||
var utc = Calendar(identifier: .gregorian)
|
||||
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let components = utc.dateComponents([.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(timestamp)))
|
||||
return Calendar.current.date(from: components) ?? Date()
|
||||
}
|
||||
|
||||
private func utcTimestamp(forLocalDate date: Date) -> Int64 {
|
||||
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||
var utc = Calendar(identifier: .gregorian)
|
||||
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
return Int64((utc.date(from: components) ?? date).timeIntervalSince1970)
|
||||
}
|
||||
}
|
||||
|
||||
198
ios/Sources/MilestoneEditorViewController.swift
Normal file
198
ios/Sources/MilestoneEditorViewController.swift
Normal file
@@ -0,0 +1,198 @@
|
||||
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) }
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
import UIKit
|
||||
|
||||
func localDate(forUTCTimestamp timestamp: Int64) -> Date {
|
||||
var utc = Calendar(identifier: .gregorian)
|
||||
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let components = utc.dateComponents(
|
||||
[.year, .month, .day],
|
||||
from: Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||
)
|
||||
return Calendar.current.date(from: components) ?? Date()
|
||||
}
|
||||
|
||||
func utcTimestamp(forLocalDate date: Date) -> Int64 {
|
||||
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||
var utc = Calendar(identifier: .gregorian)
|
||||
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
return Int64((utc.date(from: components) ?? date).timeIntervalSince1970)
|
||||
}
|
||||
|
||||
func errorAlert(_ message: String) -> UIAlertController {
|
||||
let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
|
||||
Reference in New Issue
Block a user