467 lines
17 KiB
Swift
467 lines
17 KiB
Swift
import UIKit
|
||
|
||
@MainActor
|
||
final class MilestonesViewController: RefreshingTableViewController {
|
||
private let context: AppContext
|
||
private let owner: String
|
||
private let repository: String
|
||
private var rows: [MilestoneRow] = []
|
||
private var mutationTask: Task<Void, Never>?
|
||
private var currentPage: UInt32 = 0
|
||
|
||
init(context: AppContext, owner: String, repository: String) {
|
||
self.context = context
|
||
self.owner = owner
|
||
self.repository = repository
|
||
super.init()
|
||
title = repository
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||
|
||
deinit { mutationTask?.cancel() }
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
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)
|
||
}
|
||
|
||
override func loadMoreContent() {
|
||
loadPage(currentPage + 1, refreshing: false)
|
||
}
|
||
|
||
private func loadPage(_ page: UInt32, refreshing: Bool) {
|
||
if page == 1 {
|
||
resetPagination()
|
||
beginLoading(refreshing: refreshing)
|
||
}
|
||
loadingTask?.cancel()
|
||
loadingTask = Task {
|
||
do {
|
||
let result = try await context.core.milestones(
|
||
owner: owner,
|
||
repository: repository,
|
||
page: page
|
||
)
|
||
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
|
||
currentPage = page
|
||
finishPagination(hasMore: result.hasMore)
|
||
tableView.reloadData()
|
||
tableView.backgroundView = rows.isEmpty
|
||
? EmptyBackgroundView(
|
||
title: "No milestones",
|
||
detail: "This repository does not have any milestones."
|
||
)
|
||
: nil
|
||
} catch {
|
||
if !Task.isCancelled {
|
||
show(error: error)
|
||
failPagination()
|
||
}
|
||
}
|
||
if page == 1 { endLoading() }
|
||
}
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
rows.count
|
||
}
|
||
|
||
override func tableView(
|
||
_ tableView: UITableView,
|
||
cellForRowAt indexPath: IndexPath
|
||
) -> UITableViewCell {
|
||
let cell = tableView.dequeueReusableCell(
|
||
withIdentifier: "milestone",
|
||
for: indexPath
|
||
) as! MilestoneCell
|
||
cell.configure(rows[indexPath.row], disclosure: true)
|
||
return cell
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
navigationController?.pushViewController(
|
||
MilestoneViewController(
|
||
context: context,
|
||
owner: owner,
|
||
repository: repository,
|
||
id: rows[indexPath.row].id
|
||
),
|
||
animated: true
|
||
)
|
||
}
|
||
|
||
override func tableView(
|
||
_ tableView: UITableView,
|
||
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||
) -> UISwipeActionsConfiguration? {
|
||
let row = rows[indexPath.row]
|
||
let close = row.state != .closed
|
||
let stateAction = UIContextualAction(
|
||
style: .normal,
|
||
title: close ? "Close" : "Open"
|
||
) { [weak self] _, _, completion in
|
||
self?.setMilestone(row, closed: close, completion: completion) ?? completion(false)
|
||
}
|
||
stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle")
|
||
stateAction.backgroundColor = close ? .systemPurple : .systemGreen
|
||
|
||
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
|
||
[weak self] _, _, completion in
|
||
self?.confirmDelete(row, completion: completion) ?? completion(false)
|
||
}
|
||
deleteAction.image = context.symbol("trash")
|
||
|
||
let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction])
|
||
configuration.performsFirstActionWithFullSwipe = true
|
||
return configuration
|
||
}
|
||
|
||
private func setMilestone(
|
||
_ row: MilestoneRow,
|
||
closed: Bool,
|
||
completion: @escaping (Bool) -> Void
|
||
) {
|
||
mutationTask?.cancel()
|
||
mutationTask = Task {
|
||
do {
|
||
try await context.core.setMilestoneClosed(
|
||
owner: owner,
|
||
repository: repository,
|
||
id: row.id,
|
||
closed: closed
|
||
)
|
||
guard !Task.isCancelled else {
|
||
completion(false)
|
||
return
|
||
}
|
||
completion(true)
|
||
loadContent(refreshing: false)
|
||
} catch {
|
||
completion(false)
|
||
if !Task.isCancelled { show(error: error) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private func confirmDelete(_ row: MilestoneRow, completion: @escaping (Bool) -> Void) {
|
||
guard !row.hasIssues else {
|
||
let alert = UIAlertController(
|
||
title: "Milestone Can’t Be Deleted",
|
||
message: "Remove all assigned issues and pull requests first.",
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completion(false) })
|
||
present(alert, animated: true)
|
||
return
|
||
}
|
||
let alert = UIAlertController(
|
||
title: "Delete \(row.title)?",
|
||
message: "This milestone will be permanently deleted. This can’t be undone.",
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
|
||
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
|
||
guard let self else {
|
||
completion(false)
|
||
return
|
||
}
|
||
self.mutationTask?.cancel()
|
||
self.mutationTask = Task {
|
||
do {
|
||
try await self.context.core.deleteMilestone(
|
||
owner: self.owner,
|
||
repository: self.repository,
|
||
id: row.id
|
||
)
|
||
guard !Task.isCancelled else {
|
||
completion(false)
|
||
return
|
||
}
|
||
completion(true)
|
||
self.loadContent(refreshing: false)
|
||
} catch {
|
||
completion(false)
|
||
if !Task.isCancelled { self.show(error: error) }
|
||
}
|
||
}
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class MilestoneViewController: RefreshingTableViewController, IssueSwipeActionHost {
|
||
let context: AppContext
|
||
let owner: String
|
||
let repository: String
|
||
private let id: Int64
|
||
private var page: MilestonePage?
|
||
var issueMutationTask: Task<Void, Never>?
|
||
private var currentPage: UInt32 = 0
|
||
|
||
init(context: AppContext, owner: String, repository: String, id: Int64) {
|
||
self.context = context
|
||
self.owner = owner
|
||
self.repository = repository
|
||
self.id = id
|
||
super.init()
|
||
title = "Milestone"
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||
|
||
deinit { issueMutationTask?.cancel() }
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
|
||
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
|
||
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)
|
||
}
|
||
|
||
override func loadMoreContent() {
|
||
loadPage(currentPage + 1, refreshing: false)
|
||
}
|
||
|
||
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||
if requestedPage == 1 {
|
||
resetPagination()
|
||
beginLoading(refreshing: refreshing)
|
||
}
|
||
loadingTask?.cancel()
|
||
loadingTask = Task {
|
||
do {
|
||
let result = try await context.core.milestone(
|
||
owner: owner,
|
||
repository: repository,
|
||
id: id,
|
||
page: requestedPage
|
||
)
|
||
if requestedPage == 1 {
|
||
page = result
|
||
} else {
|
||
page?.issues.append(contentsOf: result.issues)
|
||
page?.pulls.append(contentsOf: result.pulls)
|
||
page?.hasMore = result.hasMore
|
||
}
|
||
currentPage = requestedPage
|
||
finishPagination(hasMore: result.hasMore)
|
||
title = page?.milestone.title
|
||
tableView.reloadData()
|
||
} catch {
|
||
if !Task.isCancelled {
|
||
show(error: error)
|
||
failPagination()
|
||
}
|
||
}
|
||
if requestedPage == 1 { endLoading() }
|
||
}
|
||
}
|
||
|
||
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 3 }
|
||
|
||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
switch section {
|
||
case 0: 1
|
||
case 1: page?.issues.count ?? 0
|
||
default: page?.pulls.count ?? 0
|
||
}
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||
switch section {
|
||
case 1: "Issues"
|
||
case 2: "Pull Requests"
|
||
default: nil
|
||
}
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||
if section == 1, page?.issues.isEmpty == true {
|
||
return "No issues are assigned to this milestone."
|
||
}
|
||
if section == 2, page?.pulls.isEmpty == true {
|
||
return "No pull requests are assigned to this milestone."
|
||
}
|
||
return nil
|
||
}
|
||
|
||
override func tableView(
|
||
_ tableView: UITableView,
|
||
cellForRowAt indexPath: IndexPath
|
||
) -> UITableViewCell {
|
||
guard let page else { return UITableViewCell() }
|
||
if indexPath.section == 0 {
|
||
let cell = tableView.dequeueReusableCell(
|
||
withIdentifier: "milestone",
|
||
for: indexPath
|
||
) as! MilestoneCell
|
||
cell.configure(page.milestone, disclosure: false)
|
||
return cell
|
||
}
|
||
if indexPath.section == 1 {
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
|
||
cell.configure(page.issues[indexPath.row])
|
||
return cell
|
||
}
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: "pull", for: indexPath) as! PullCell
|
||
cell.configure(page.pulls[indexPath.row])
|
||
return cell
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
if indexPath.section == 1, let issue = page?.issues[indexPath.row] {
|
||
navigationController?.pushViewController(
|
||
IssueViewController(
|
||
context: context,
|
||
owner: owner,
|
||
repository: repository,
|
||
number: issue.number
|
||
),
|
||
animated: true
|
||
)
|
||
} else if indexPath.section == 2, let pull = page?.pulls[indexPath.row] {
|
||
navigationController?.pushViewController(
|
||
PullViewController(
|
||
context: context,
|
||
owner: pull.owner,
|
||
repository: pull.repository,
|
||
number: pull.number
|
||
),
|
||
animated: true
|
||
)
|
||
}
|
||
}
|
||
|
||
override func tableView(
|
||
_ tableView: UITableView,
|
||
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||
) -> UISwipeActionsConfiguration? {
|
||
guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return nil }
|
||
return issueSwipeActions(for: issue)
|
||
}
|
||
|
||
func reloadIssuesAfterMutation() {
|
||
loadContent(refreshing: false)
|
||
}
|
||
}
|
||
|
||
final class MilestoneCell: UITableViewCell {
|
||
private let stateIcon = UIImageView()
|
||
private let titleLabel = UILabel()
|
||
private let stack = UIStackView()
|
||
private var descriptionView: UIView?
|
||
private let metaLabel = UILabel()
|
||
private let progress = UIProgressView(progressViewStyle: .bar)
|
||
|
||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||
titleLabel.numberOfLines = 2
|
||
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
|
||
titleStack.alignment = .firstBaseline
|
||
titleStack.spacing = 8
|
||
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
||
metaLabel.textColor = .tertiaryLabel
|
||
metaLabel.numberOfLines = 2
|
||
progress.progressTintColor = .systemGreen
|
||
stack.addArrangedSubview(titleStack)
|
||
stack.addArrangedSubview(progress)
|
||
stack.addArrangedSubview(metaLabel)
|
||
stack.axis = .vertical
|
||
stack.spacing = 7
|
||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||
contentView.addSubview(stack)
|
||
NSLayoutConstraint.activate([
|
||
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
|
||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
|
||
])
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||
|
||
func configure(_ row: MilestoneRow, disclosure: Bool) {
|
||
accessoryType = disclosure ? .disclosureIndicator : .none
|
||
configureOpenClosedStateIcon(
|
||
stateIcon,
|
||
state: row.state,
|
||
subject: "milestone",
|
||
textStyle: .headline
|
||
)
|
||
titleLabel.text = row.title
|
||
descriptionView?.removeFromSuperview()
|
||
if !row.description.isEmpty {
|
||
let view = markdownView(row.description)
|
||
stack.insertArrangedSubview(view, at: 1)
|
||
descriptionView = view
|
||
}
|
||
metaLabel.text = row.meta
|
||
progress.progress = Float(row.progress)
|
||
progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5
|
||
progress.accessibilityLabel = "Milestone progress"
|
||
progress.accessibilityValue = row.progressAccessibility
|
||
}
|
||
}
|