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? 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? 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 } }