324 lines
12 KiB
Swift
324 lines
12 KiB
Swift
import UIKit
|
|
|
|
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"
|
|
}
|
|
}
|