Prepare Gotcha 1.0 for release
This commit is contained in:
439
ios/Sources/IssueScreens.swift
Normal file
439
ios/Sources/IssueScreens.swift
Normal file
@@ -0,0 +1,439 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class IssuesViewController: RefreshingTableViewController, IssueSwipeActionHost {
|
||||
let context: AppContext
|
||||
let owner: String
|
||||
let repository: String
|
||||
private var rows: [IssueRow] = []
|
||||
private var filterOptions: IssueFilterOptions?
|
||||
private var filterTask: Task<Void, Never>?
|
||||
var issueMutationTask: 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()
|
||||
issueMutationTask?.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
|
||||
)
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
|
||||
) -> UISwipeActionsConfiguration? {
|
||||
issueSwipeActions(for: rows[indexPath.row])
|
||||
}
|
||||
|
||||
func reloadIssuesAfterMutation() {
|
||||
loadIssues(refreshing: false)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user