1412 lines
50 KiB
Swift
1412 lines
50 KiB
Swift
import UIKit
|
|
|
|
private extension UIViewController {
|
|
func promptForSearchText(title: String, current: String, apply: @escaping (String) -> Void) {
|
|
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
|
|
alert.addTextField { field in
|
|
field.text = current
|
|
field.placeholder = "Search text"
|
|
field.accessibilityLabel = "Search text"
|
|
field.clearButtonMode = .whileEditing
|
|
field.returnKeyType = .search
|
|
}
|
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
alert.addAction(UIAlertAction(title: "Apply", style: .default) { [weak alert] _ in
|
|
apply(alert?.textFields?.first?.text ?? "")
|
|
})
|
|
present(alert, animated: true)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class IssuesViewController: RefreshingTableViewController {
|
|
private let context: AppContext
|
|
private let owner: String
|
|
private let repository: String
|
|
private var rows: [IssueRow] = []
|
|
private var filterOptions: IssueFilterOptions?
|
|
private var filterTask: Task<Void, Never>?
|
|
private var currentPage: UInt32 = 0
|
|
private lazy var filterButton = UIBarButtonItem(
|
|
image: context.symbol("line.3.horizontal.decrease.circle")
|
|
)
|
|
|
|
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 { filterTask?.cancel() }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
|
let addButton = UIBarButtonItem(
|
|
barButtonSystemItem: .add,
|
|
target: self,
|
|
action: #selector(createIssue)
|
|
)
|
|
addButton.accessibilityLabel = "New issue"
|
|
navigationItem.rightBarButtonItems = [addButton, filterButton]
|
|
updateFilterMenu()
|
|
loadContent(refreshing: false)
|
|
}
|
|
|
|
override func loadContent(refreshing: Bool) {
|
|
loadFilterOptions()
|
|
loadIssues(refreshing: refreshing)
|
|
}
|
|
|
|
private func loadIssues(refreshing: Bool) {
|
|
loadIssues(page: 1, refreshing: refreshing)
|
|
}
|
|
|
|
override func loadMoreContent() {
|
|
loadIssues(page: currentPage + 1, refreshing: false)
|
|
}
|
|
|
|
private func loadIssues(page: UInt32, refreshing: Bool) {
|
|
if page == 1 {
|
|
resetPagination()
|
|
beginLoading(refreshing: refreshing)
|
|
}
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
let result = try await context.core.issues(
|
|
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()
|
|
updateEmptyState()
|
|
} catch {
|
|
if !Task.isCancelled {
|
|
show(error: error)
|
|
failPagination()
|
|
}
|
|
}
|
|
if page == 1 { endLoading() }
|
|
}
|
|
}
|
|
|
|
private func loadFilterOptions() {
|
|
filterTask?.cancel()
|
|
filterTask = Task {
|
|
do {
|
|
let options = try await context.core.issueFilters(
|
|
owner: owner,
|
|
repository: repository
|
|
)
|
|
guard !Task.isCancelled else { return }
|
|
filterOptions = options
|
|
updateFilterMenu()
|
|
updateEmptyState()
|
|
} catch {
|
|
if !Task.isCancelled { show(error: error) }
|
|
}
|
|
}
|
|
}
|
|
|
|
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: "issue", for: indexPath) as! IssueCell
|
|
cell.configure(rows[indexPath.row])
|
|
return cell
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
navigationController?.pushViewController(
|
|
IssueViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
number: rows[indexPath.row].number
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
|
|
private func updateFilterMenu() {
|
|
let current = context.core.settings().issueStatus
|
|
let status = UIMenu(
|
|
title: "Status",
|
|
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
|
|
options: .singleSelection,
|
|
children: ["open", "closed"].map { status in
|
|
UIAction(
|
|
title: status.capitalized,
|
|
state: current == status ? .on : .off
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
do {
|
|
try self.context.core.setIssueStatus(status: status)
|
|
self.updateFilterMenu()
|
|
self.loadIssues(refreshing: false)
|
|
} catch {
|
|
self.show(error: error)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
var children: [UIMenuElement] = [status]
|
|
if let filterOptions {
|
|
children.insert(searchAction(filterOptions), at: 0)
|
|
children.append(milestoneMenu(filterOptions))
|
|
children.append(labelMenu(filterOptions))
|
|
} else {
|
|
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
|
|
}
|
|
children.append(clearFiltersMenu())
|
|
filterButton.menu = UIMenu(children: children)
|
|
filterButton.accessibilityLabel = "Filter issues"
|
|
updateFilterTint()
|
|
}
|
|
|
|
private func searchAction(_ options: IssueFilterOptions) -> UIAction {
|
|
UIAction(
|
|
title: "Search Text",
|
|
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
|
|
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
|
|
) { [weak self] _ in
|
|
self?.promptForSearchText(
|
|
title: "Search Issues",
|
|
current: options.searchText
|
|
) { [weak self] searchText in
|
|
self?.setSearchText(searchText)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu {
|
|
let selected = options.selectedMilestone
|
|
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
|
|
[weak self] _ in self?.selectMilestone("")
|
|
}
|
|
let actions = options.milestones.map { milestone in
|
|
UIAction(
|
|
title: milestone,
|
|
state: selected == milestone ? .on : .off
|
|
) { [weak self] _ in self?.selectMilestone(milestone) }
|
|
}
|
|
return UIMenu(
|
|
title: "Milestone",
|
|
image: filterMenuImage("flag", active: !selected.isEmpty),
|
|
options: .singleSelection,
|
|
children: [all] + actions
|
|
)
|
|
}
|
|
|
|
private func labelMenu(_ options: IssueFilterOptions) -> UIMenu {
|
|
let selected = Set(options.selectedLabels)
|
|
let actions = options.labels.map { label in
|
|
UIAction(
|
|
title: options.unavailableLabels.contains(label) ? "\(label) (Unavailable)" : label,
|
|
attributes: .keepsMenuPresented,
|
|
state: selected.contains(label) ? .on : .off
|
|
) { [weak self] action in
|
|
guard let self, let options = self.filterOptions else { return }
|
|
self.filterTask?.cancel()
|
|
var labels = Set(options.selectedLabels)
|
|
if labels.remove(label) == nil { labels.insert(label) }
|
|
do {
|
|
try self.saveFilters(
|
|
milestone: options.selectedMilestone,
|
|
labels: labels,
|
|
searchText: options.searchText
|
|
)
|
|
self.filterOptions?.selectedLabels = Array(labels)
|
|
action.state = labels.contains(label) ? .on : .off
|
|
self.updateFilterMenu()
|
|
self.loadIssues(refreshing: false)
|
|
} catch {
|
|
self.show(error: error)
|
|
}
|
|
}
|
|
}
|
|
return UIMenu(
|
|
title: "Labels",
|
|
image: filterMenuImage("tag", active: !selected.isEmpty),
|
|
children: actions.isEmpty
|
|
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
|
|
: actions
|
|
)
|
|
}
|
|
|
|
private func selectMilestone(_ milestone: String) {
|
|
guard let options = filterOptions else { return }
|
|
filterTask?.cancel()
|
|
do {
|
|
try saveFilters(
|
|
milestone: milestone,
|
|
labels: Set(options.selectedLabels),
|
|
searchText: options.searchText
|
|
)
|
|
filterOptions?.selectedMilestone = milestone
|
|
updateFilterMenu()
|
|
loadIssues(refreshing: false)
|
|
} catch {
|
|
show(error: error)
|
|
}
|
|
}
|
|
|
|
private func setSearchText(_ searchText: String) {
|
|
guard let options = filterOptions else { return }
|
|
filterTask?.cancel()
|
|
do {
|
|
try saveFilters(
|
|
milestone: options.selectedMilestone,
|
|
labels: Set(options.selectedLabels),
|
|
searchText: searchText
|
|
)
|
|
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
updateFilterMenu()
|
|
loadIssues(refreshing: false)
|
|
} catch {
|
|
show(error: error)
|
|
}
|
|
}
|
|
|
|
private func saveFilters(
|
|
milestone: String,
|
|
labels: Set<String>,
|
|
searchText: String
|
|
) throws {
|
|
try context.core.setIssueFilters(
|
|
owner: owner,
|
|
repository: repository,
|
|
milestone: milestone,
|
|
labels: Array(labels),
|
|
searchText: searchText
|
|
)
|
|
}
|
|
|
|
private func clearFiltersMenu() -> UIMenu {
|
|
UIMenu(
|
|
options: .displayInline,
|
|
children: [
|
|
UIAction(
|
|
title: "Clear Filters",
|
|
image: context.symbol("xmark.circle"),
|
|
attributes: filtersActive ? [] : .disabled
|
|
) { [weak self] _ in self?.clearFilters() },
|
|
]
|
|
)
|
|
}
|
|
|
|
private func clearFilters() {
|
|
filterTask?.cancel()
|
|
do {
|
|
try context.core.clearIssueFilters(owner: owner, repository: repository)
|
|
filterOptions?.selectedMilestone = ""
|
|
filterOptions?.selectedLabels = []
|
|
filterOptions?.searchText = ""
|
|
updateFilterMenu()
|
|
loadIssues(refreshing: false)
|
|
} catch {
|
|
show(error: error)
|
|
}
|
|
}
|
|
|
|
private var filtersActive: Bool {
|
|
(try? context.core.issueFiltersActive(owner: owner, repository: repository)) ?? false
|
|
}
|
|
|
|
private func updateFilterTint() {
|
|
filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel
|
|
filterButton.accessibilityValue = filtersActive
|
|
? "Filters active"
|
|
: "Default filters"
|
|
}
|
|
|
|
@objc private func createIssue() {
|
|
let editor = IssueEditorViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository
|
|
) { [weak self] number in
|
|
guard let self else { return }
|
|
self.dismiss(animated: true) {
|
|
self.loadContent(refreshing: false)
|
|
self.navigationController?.pushViewController(
|
|
IssueViewController(
|
|
context: self.context,
|
|
owner: self.owner,
|
|
repository: self.repository,
|
|
number: number
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
}
|
|
present(UINavigationController(rootViewController: editor), animated: true)
|
|
}
|
|
|
|
private func updateEmptyState() {
|
|
guard rows.isEmpty else {
|
|
tableView.backgroundView = nil
|
|
return
|
|
}
|
|
let status = context.core.settings().issueStatus
|
|
tableView.backgroundView = EmptyBackgroundView(
|
|
title: "No \(status) issues",
|
|
detail: filtersActive
|
|
? "No issues match the selected filters."
|
|
: "This repository has no \(status) issues."
|
|
)
|
|
}
|
|
}
|
|
|
|
final class IssueCell: UITableViewCell {
|
|
private let stateIcon = UIImageView()
|
|
private let titleLabel = UILabel()
|
|
private let summaryLabel = UILabel()
|
|
private let labels = UIStackView()
|
|
private let metaLabel = UILabel()
|
|
private let milestoneLabel = UILabel()
|
|
|
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
accessoryType = .disclosureIndicator
|
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
|
titleLabel.numberOfLines = 2
|
|
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
|
|
titleStack.alignment = .firstBaseline
|
|
titleStack.spacing = 8
|
|
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
|
|
summaryLabel.textColor = .secondaryLabel
|
|
summaryLabel.numberOfLines = 2
|
|
labels.axis = .horizontal
|
|
labels.spacing = 5
|
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
|
metaLabel.textColor = .tertiaryLabel
|
|
metaLabel.numberOfLines = 2
|
|
milestoneLabel.font = .preferredFont(forTextStyle: .caption1)
|
|
milestoneLabel.adjustsFontForContentSizeCategory = true
|
|
let stack = UIStackView(
|
|
arrangedSubviews: [titleStack, summaryLabel, labels, metaLabel, milestoneLabel]
|
|
)
|
|
stack.axis = .vertical
|
|
stack.spacing = 6
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
|
])
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
func configure(_ row: IssueRow) {
|
|
configureIssueStateIcon(stateIcon, state: row.state, textStyle: .headline)
|
|
titleLabel.text = row.title
|
|
summaryLabel.text = row.summary
|
|
metaLabel.text = row.meta
|
|
milestoneLabel.isHidden = row.milestone.isEmpty
|
|
milestoneLabel.attributedText = symbolText(
|
|
"flag.fill",
|
|
text: row.milestone,
|
|
font: milestoneLabel.font,
|
|
color: .tertiaryLabel
|
|
)
|
|
milestoneLabel.accessibilityLabel = row.milestone.isEmpty
|
|
? nil
|
|
: "Milestone \(row.milestone)"
|
|
labels.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
labels.isHidden = row.labels.isEmpty
|
|
for label in row.labels.prefix(3) {
|
|
labels.addArrangedSubview(issueLabelView(label))
|
|
}
|
|
labels.addArrangedSubview(UIView())
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MilestonesViewController: RefreshingTableViewController {
|
|
private let context: AppContext
|
|
private let owner: String
|
|
private let repository: String
|
|
private var rows: [MilestoneRow] = []
|
|
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") }
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MilestoneViewController: RefreshingTableViewController {
|
|
private let context: AppContext
|
|
private let owner: String
|
|
private let repository: String
|
|
private let id: Int64
|
|
private var page: MilestonePage?
|
|
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") }
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class MilestoneCell: UITableViewCell {
|
|
private let stateIcon = UIImageView()
|
|
private let titleLabel = UILabel()
|
|
private let descriptionLabel = UILabel()
|
|
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
|
|
descriptionLabel.font = .preferredFont(forTextStyle: .subheadline)
|
|
descriptionLabel.textColor = .secondaryLabel
|
|
descriptionLabel.numberOfLines = 2
|
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
|
metaLabel.textColor = .tertiaryLabel
|
|
metaLabel.numberOfLines = 2
|
|
progress.progressTintColor = .systemGreen
|
|
let stack = UIStackView(
|
|
arrangedSubviews: [titleStack, descriptionLabel, progress, 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
|
|
descriptionLabel.text = row.description
|
|
metaLabel.text = row.meta
|
|
progress.progress = Float(row.progress)
|
|
progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5
|
|
progress.accessibilityLabel = "Milestone progress"
|
|
progress.accessibilityValue = row.progressAccessibility
|
|
}
|
|
}
|
|
|
|
final class PullCell: UITableViewCell {
|
|
private let stateIcon = UIImageView()
|
|
private let titleLabel = UILabel()
|
|
private let summaryLabel = UILabel()
|
|
private let metaLabel = UILabel()
|
|
|
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
accessoryType = .disclosureIndicator
|
|
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
|
titleLabel.numberOfLines = 2
|
|
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
|
|
titleStack.alignment = .firstBaseline
|
|
titleStack.spacing = 8
|
|
summaryLabel.font = .preferredFont(forTextStyle: .subheadline)
|
|
summaryLabel.textColor = .secondaryLabel
|
|
summaryLabel.numberOfLines = 2
|
|
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
|
metaLabel.textColor = .tertiaryLabel
|
|
metaLabel.numberOfLines = 2
|
|
[titleLabel, summaryLabel, metaLabel].forEach {
|
|
$0.adjustsFontForContentSizeCategory = true
|
|
}
|
|
let stack = UIStackView(arrangedSubviews: [titleStack, summaryLabel, metaLabel])
|
|
stack.axis = .vertical
|
|
stack.spacing = 6
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
|
])
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
func configure(_ row: PullRow) {
|
|
configureOpenClosedStateIcon(
|
|
stateIcon,
|
|
state: row.state,
|
|
subject: "pull request",
|
|
textStyle: .headline
|
|
)
|
|
titleLabel.text = row.title
|
|
summaryLabel.text = row.summary
|
|
metaLabel.text = row.meta
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class PullsViewController: RefreshingTableViewController {
|
|
private let context: AppContext
|
|
private var rows: [PullRow] = []
|
|
private var filterOptions: PullFilterOptions?
|
|
private var filterTask: Task<Void, Never>?
|
|
private var currentPage: UInt32 = 0
|
|
|
|
init(context: AppContext) {
|
|
self.context = context
|
|
super.init()
|
|
title = "Pull Requests"
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
deinit { filterTask?.cancel() }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
|
|
tableView.rowHeight = UITableView.automaticDimension
|
|
tableView.estimatedRowHeight = 116
|
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
|
image: context.symbol("server.rack"),
|
|
primaryAction: UIAction { [weak self] _ in
|
|
guard let self else { return }
|
|
self.navigationController?.pushViewController(
|
|
ServersViewController(context: self.context),
|
|
animated: true
|
|
)
|
|
}
|
|
)
|
|
updateFilterMenu()
|
|
loadContent(refreshing: false)
|
|
}
|
|
|
|
override func loadContent(refreshing: Bool) {
|
|
guard context.core.activeServerIndex() != nil else {
|
|
tableView.backgroundView = EmptyBackgroundView(
|
|
title: "No server selected",
|
|
detail: "Open Issues or Repos to select a server or add your first one."
|
|
)
|
|
refreshControl?.endRefreshing()
|
|
return
|
|
}
|
|
loadFilterOptions()
|
|
loadPulls(refreshing: refreshing)
|
|
}
|
|
|
|
private func loadPulls(refreshing: Bool) {
|
|
loadPulls(page: 1, refreshing: refreshing)
|
|
}
|
|
|
|
override func loadMoreContent() {
|
|
loadPulls(page: currentPage + 1, refreshing: false)
|
|
}
|
|
|
|
private func loadPulls(page: UInt32, refreshing: Bool) {
|
|
if page == 1 {
|
|
resetPagination()
|
|
beginLoading(refreshing: refreshing)
|
|
}
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
let result = try await context.core.pulls(page: page)
|
|
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
|
|
currentPage = page
|
|
finishPagination(hasMore: result.hasMore)
|
|
tableView.reloadData()
|
|
let status = context.core.settings().pullStatus
|
|
tableView.backgroundView = rows.isEmpty
|
|
? EmptyBackgroundView(
|
|
title: "No \(status) pull requests",
|
|
detail: filtersActive
|
|
? "No pull requests match the selected filters."
|
|
: "No pull requests match the selected status."
|
|
)
|
|
: nil
|
|
} catch {
|
|
if !Task.isCancelled {
|
|
show(error: error)
|
|
failPagination()
|
|
}
|
|
}
|
|
if page == 1 { endLoading() }
|
|
}
|
|
}
|
|
|
|
private func loadFilterOptions() {
|
|
filterTask?.cancel()
|
|
filterTask = Task {
|
|
do {
|
|
filterOptions = try await context.core.pullFilters()
|
|
guard !Task.isCancelled else { return }
|
|
updateFilterMenu()
|
|
} catch {
|
|
if !Task.isCancelled { show(error: error) }
|
|
}
|
|
}
|
|
}
|
|
|
|
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: "pull", for: indexPath) as! PullCell
|
|
cell.configure(rows[indexPath.row])
|
|
return cell
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
let row = rows[indexPath.row]
|
|
navigationController?.pushViewController(
|
|
PullViewController(
|
|
context: context,
|
|
owner: row.owner,
|
|
repository: row.repository,
|
|
number: row.number
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
|
|
private func updateFilterMenu() {
|
|
let current = context.core.settings().pullStatus
|
|
let status = UIMenu(
|
|
title: "Status",
|
|
image: filterMenuImage("circle.lefthalf.filled", active: current != "open"),
|
|
options: .singleSelection,
|
|
children: ["open", "closed"].map { status in
|
|
UIAction(title: status.capitalized, state: current == status ? .on : .off) {
|
|
[weak self] _ in
|
|
guard let self else { return }
|
|
do {
|
|
try self.context.core.setPullStatus(status: status)
|
|
self.updateFilterMenu()
|
|
self.loadPulls(refreshing: false)
|
|
} catch {
|
|
self.show(error: error)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
let milestone: UIMenuElement = filterOptions.map(milestoneMenu)
|
|
?? UIAction(title: "Loading milestones…", attributes: .disabled) { _ in }
|
|
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
|
|
image: context.symbol("line.3.horizontal.decrease.circle")
|
|
)
|
|
var children: [UIMenuElement] = [status]
|
|
if let filterOptions {
|
|
children.insert(searchAction(filterOptions), at: 0)
|
|
}
|
|
children.append(milestone)
|
|
children.append(clearFiltersMenu())
|
|
item.menu = UIMenu(children: children)
|
|
item.accessibilityLabel = "Filter pull requests"
|
|
navigationItem.rightBarButtonItem = item
|
|
updateFilterTint()
|
|
}
|
|
|
|
private func searchAction(_ options: PullFilterOptions) -> UIAction {
|
|
UIAction(
|
|
title: "Search Text",
|
|
subtitle: options.searchText.isEmpty ? "Any text" : options.searchText,
|
|
image: filterMenuImage("magnifyingglass", active: !options.searchText.isEmpty)
|
|
) { [weak self] _ in
|
|
self?.promptForSearchText(
|
|
title: "Search Pull Requests",
|
|
current: options.searchText
|
|
) { [weak self] searchText in
|
|
self?.setSearchText(searchText)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func milestoneMenu(_ options: PullFilterOptions) -> UIMenu {
|
|
let selected = options.selectedMilestone
|
|
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
|
|
[weak self] _ in self?.selectMilestone("")
|
|
}
|
|
let milestones = options.milestones.map { milestone in
|
|
UIAction(title: milestone, state: selected == milestone ? .on : .off) {
|
|
[weak self] _ in self?.selectMilestone(milestone)
|
|
}
|
|
}
|
|
return UIMenu(
|
|
title: "Milestone",
|
|
image: filterMenuImage("flag", active: !selected.isEmpty),
|
|
options: .singleSelection,
|
|
children: [all] + milestones
|
|
)
|
|
}
|
|
|
|
private func selectMilestone(_ milestone: String) {
|
|
guard let options = filterOptions else { return }
|
|
filterTask?.cancel()
|
|
do {
|
|
try context.core.setPullFilters(
|
|
milestone: milestone,
|
|
searchText: options.searchText
|
|
)
|
|
filterOptions?.selectedMilestone = milestone
|
|
updateFilterMenu()
|
|
loadPulls(refreshing: false)
|
|
} catch {
|
|
show(error: error)
|
|
}
|
|
}
|
|
|
|
private func setSearchText(_ searchText: String) {
|
|
guard let options = filterOptions else { return }
|
|
filterTask?.cancel()
|
|
do {
|
|
try context.core.setPullFilters(
|
|
milestone: options.selectedMilestone,
|
|
searchText: searchText
|
|
)
|
|
filterOptions?.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
updateFilterMenu()
|
|
loadPulls(refreshing: false)
|
|
} catch {
|
|
show(error: error)
|
|
}
|
|
}
|
|
|
|
private func clearFiltersMenu() -> UIMenu {
|
|
UIMenu(
|
|
options: .displayInline,
|
|
children: [
|
|
UIAction(
|
|
title: "Clear Filters",
|
|
image: context.symbol("xmark.circle"),
|
|
attributes: filtersActive ? [] : .disabled
|
|
) { [weak self] _ in self?.clearFilters() },
|
|
]
|
|
)
|
|
}
|
|
|
|
private func clearFilters() {
|
|
filterTask?.cancel()
|
|
do {
|
|
try context.core.clearPullFilters()
|
|
filterOptions?.selectedMilestone = ""
|
|
filterOptions?.searchText = ""
|
|
updateFilterMenu()
|
|
loadPulls(refreshing: false)
|
|
} catch {
|
|
show(error: error)
|
|
}
|
|
}
|
|
|
|
private var filtersActive: Bool {
|
|
(try? context.core.pullFiltersActive()) ?? false
|
|
}
|
|
|
|
private func updateFilterTint() {
|
|
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
|
|
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
|
|
? "Filters active"
|
|
: "Default filters"
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class CommitsViewController: RefreshingTableViewController {
|
|
private enum Mode: Int { case history, files }
|
|
|
|
private let context: AppContext
|
|
private let owner: String
|
|
private let repository: String
|
|
private var page: CommitPage?
|
|
private var contents: [RepositoryContentRow] = []
|
|
private var branch: String?
|
|
private var mode = Mode.history
|
|
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") }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
tableView.register(CommitCell.self, forCellReuseIdentifier: "commit")
|
|
let modeControl = UISegmentedControl(items: ["History", "Files"])
|
|
modeControl.selectedSegmentIndex = mode.rawValue
|
|
modeControl.addTarget(self, action: #selector(modeChanged(_:)), for: .valueChanged)
|
|
modeControl.accessibilityLabel = "Repository view"
|
|
navigationItem.titleView = modeControl
|
|
updateBranchMenu()
|
|
loadContent(refreshing: false)
|
|
}
|
|
|
|
override func loadContent(refreshing: Bool) {
|
|
loadPage(1, refreshing: refreshing)
|
|
}
|
|
|
|
override func loadMoreContent() {
|
|
guard mode == .history else { return }
|
|
loadPage(currentPage + 1, refreshing: false)
|
|
}
|
|
|
|
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
|
if requestedPage == 1 {
|
|
resetPagination()
|
|
beginLoading(refreshing: refreshing)
|
|
}
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
switch mode {
|
|
case .history:
|
|
page = try await context.core.commits(
|
|
owner: owner,
|
|
repository: repository,
|
|
branch: branch,
|
|
path: "",
|
|
pages: requestedPage
|
|
)
|
|
currentPage = requestedPage
|
|
finishPagination(hasMore: page?.hasMore ?? false)
|
|
updateBranchMenu()
|
|
case .files:
|
|
contents = try await context.core.repositoryContents(
|
|
owner: owner,
|
|
repository: repository,
|
|
path: ""
|
|
)
|
|
}
|
|
tableView.reloadData()
|
|
updateEmptyView()
|
|
} catch {
|
|
if !Task.isCancelled {
|
|
show(error: error)
|
|
failPagination()
|
|
}
|
|
}
|
|
if requestedPage == 1 { endLoading() }
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
mode == .history ? page?.commits.count ?? 0 : contents.count
|
|
}
|
|
|
|
override func tableView(
|
|
_ tableView: UITableView,
|
|
cellForRowAt indexPath: IndexPath
|
|
) -> UITableViewCell {
|
|
switch mode {
|
|
case .history:
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
|
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
|
|
return cell
|
|
case .files:
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "repository-content")
|
|
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content")
|
|
configureRepositoryContentCell(cell, row: contents[indexPath.row])
|
|
return cell
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
mode == .history ? 86 : UITableView.automaticDimension
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
switch mode {
|
|
case .history:
|
|
guard let row = page?.commits[indexPath.row] else { return }
|
|
navigationController?.pushViewController(
|
|
FilesViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
sha: row.sha
|
|
),
|
|
animated: true
|
|
)
|
|
case .files:
|
|
showRepositoryContent(
|
|
contents[indexPath.row],
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
navigationController: navigationController
|
|
)
|
|
}
|
|
}
|
|
|
|
@objc private func modeChanged(_ sender: UISegmentedControl) {
|
|
guard let mode = Mode(rawValue: sender.selectedSegmentIndex), mode != self.mode else { return }
|
|
self.mode = mode
|
|
navigationItem.rightBarButtonItem = nil
|
|
if mode == .history { updateBranchMenu() }
|
|
tableView.reloadData()
|
|
loadContent(refreshing: false)
|
|
}
|
|
|
|
private func updateEmptyView() {
|
|
switch mode {
|
|
case .history:
|
|
tableView.backgroundView = page?.commits.isEmpty == true
|
|
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
|
|
: nil
|
|
case .files:
|
|
tableView.backgroundView = contents.isEmpty
|
|
? EmptyBackgroundView(title: "No files", detail: "This repository is empty.")
|
|
: nil
|
|
}
|
|
}
|
|
|
|
private func updateBranchMenu() {
|
|
let branches = page?.branches ?? []
|
|
let choices: [String?] = [nil] + branches.map(Optional.some)
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: branch ?? "All",
|
|
menu: UIMenu(children: choices.map { choice in
|
|
UIAction(
|
|
title: choice ?? "All",
|
|
state: choice == branch ? .on : .off
|
|
) { [weak self] _ in
|
|
self?.branch = choice
|
|
self?.updateBranchMenu()
|
|
self?.loadContent(refreshing: false)
|
|
}
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
final class CommitCell: UITableViewCell {
|
|
private static let laneOrigin: CGFloat = 12
|
|
private static let laneSpacing: CGFloat = 12
|
|
|
|
private var row: CommitRow?
|
|
private var laneCount: UInt32 = 0
|
|
|
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
accessoryType = .disclosureIndicator
|
|
backgroundColor = .systemBackground
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
func configure(_ row: CommitRow, laneCount: UInt32) {
|
|
self.row = row
|
|
self.laneCount = laneCount
|
|
var content = defaultContentConfiguration()
|
|
content.directionalLayoutMargins.leading = laneCount == 0
|
|
? 0
|
|
: CGFloat(laneCount) * Self.laneSpacing + 10
|
|
content.text = row.title
|
|
content.secondaryText = row.detail
|
|
content.secondaryTextProperties.numberOfLines = 2
|
|
contentConfiguration = content
|
|
setNeedsDisplay()
|
|
}
|
|
|
|
override func draw(_ rect: CGRect) {
|
|
super.draw(rect)
|
|
guard let row, laneCount > 0, let context = UIGraphicsGetCurrentContext() else { return }
|
|
let centerY = bounds.midY
|
|
let nodeX = row.nodeLane.map(laneX)
|
|
context.setLineWidth(3)
|
|
context.setLineCap(.round)
|
|
context.setLineJoin(.round)
|
|
|
|
for lane in 0..<laneCount {
|
|
if row.topLanes.contains(lane) {
|
|
let start = CGPoint(x: laneX(lane), y: 0)
|
|
if row.topConnections.contains(lane), let nodeX {
|
|
strokeCurve(
|
|
context,
|
|
from: start,
|
|
to: CGPoint(x: nodeX, y: centerY),
|
|
color: laneColor(lane)
|
|
)
|
|
} else {
|
|
strokeLine(
|
|
context,
|
|
from: start,
|
|
to: CGPoint(x: start.x, y: centerY),
|
|
color: laneColor(lane)
|
|
)
|
|
}
|
|
}
|
|
if row.bottomLanes.contains(lane) {
|
|
let end = CGPoint(x: laneX(lane), y: bounds.height)
|
|
if !row.bottomConnections.contains(lane) || row.topLanes.contains(lane) {
|
|
strokeLine(
|
|
context,
|
|
from: CGPoint(x: end.x, y: centerY),
|
|
to: end,
|
|
color: laneColor(lane)
|
|
)
|
|
}
|
|
if row.bottomConnections.contains(lane), let nodeX {
|
|
strokeCurve(
|
|
context,
|
|
from: CGPoint(x: nodeX, y: centerY),
|
|
to: end,
|
|
color: laneColor(lane)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if let lane = row.nodeLane, let nodeX {
|
|
context.setFillColor(laneColor(lane).cgColor)
|
|
context.fillEllipse(
|
|
in: CGRect(x: nodeX - 5, y: centerY - 5, width: 10, height: 10)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func laneX(_ lane: UInt32) -> CGFloat {
|
|
Self.laneOrigin + CGFloat(lane) * Self.laneSpacing
|
|
}
|
|
|
|
private func strokeLine(
|
|
_ context: CGContext,
|
|
from start: CGPoint,
|
|
to end: CGPoint,
|
|
color: UIColor
|
|
) {
|
|
context.setStrokeColor(color.cgColor)
|
|
context.move(to: start)
|
|
context.addLine(to: end)
|
|
context.strokePath()
|
|
}
|
|
|
|
private func strokeCurve(
|
|
_ context: CGContext,
|
|
from start: CGPoint,
|
|
to end: CGPoint,
|
|
color: UIColor
|
|
) {
|
|
let middleY = (start.y + end.y) / 2
|
|
context.setStrokeColor(color.cgColor)
|
|
context.move(to: start)
|
|
context.addCurve(
|
|
to: end,
|
|
control1: CGPoint(x: start.x, y: middleY),
|
|
control2: CGPoint(x: end.x, y: middleY)
|
|
)
|
|
context.strokePath()
|
|
}
|
|
|
|
private func laneColor(_ lane: UInt32) -> UIColor {
|
|
UIColor(hue: CGFloat((Double(lane) * 137.508).truncatingRemainder(dividingBy: 360)) / 360,
|
|
saturation: 0.78, brightness: 0.78, alpha: 1)
|
|
}
|
|
}
|