Prepare Gotcha 1.0 for release
This commit is contained in:
466
ios/Sources/MilestoneScreens.swift
Normal file
466
ios/Sources/MilestoneScreens.swift
Normal file
@@ -0,0 +1,466 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user