Prepare Gotcha 1.0 for release
This commit is contained in:
495
ios/Sources/WorkItemDetailScreens.swift
Normal file
495
ios/Sources/WorkItemDetailScreens.swift
Normal file
@@ -0,0 +1,495 @@
|
||||
import Highlighter
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
class MarkdownPageViewController: UIViewController, UIScrollViewDelegate {
|
||||
let context: AppContext
|
||||
let scrollView = UIScrollView()
|
||||
let stack = UIStackView()
|
||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||
var loadingTask: Task<Void, Never>?
|
||||
private lazy var moreButton = UIButton(
|
||||
configuration: .plain(),
|
||||
primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
|
||||
)
|
||||
private var hasMoreContent = false
|
||||
private var loadingMore = false
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemGroupedBackground
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.delegate = self
|
||||
scrollView.refreshControl = UIRefreshControl()
|
||||
scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
view.addSubview(scrollView)
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 18),
|
||||
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -18),
|
||||
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 18),
|
||||
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -18),
|
||||
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -36),
|
||||
])
|
||||
}
|
||||
|
||||
deinit { loadingTask?.cancel() }
|
||||
|
||||
func loadContent(refreshing: Bool) {}
|
||||
|
||||
func loadMoreContent() {}
|
||||
|
||||
func beginLoading(refreshing: Bool) {
|
||||
if !refreshing { beginNavigationLoading(spinner) }
|
||||
}
|
||||
|
||||
func endLoading() {
|
||||
endNavigationLoading(spinner)
|
||||
scrollView.refreshControl?.endRefreshing()
|
||||
}
|
||||
|
||||
func replaceContent(_ views: [UIView]) {
|
||||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
views.forEach(stack.addArrangedSubview)
|
||||
}
|
||||
|
||||
func resetPagination() {
|
||||
hasMoreContent = false
|
||||
loadingMore = false
|
||||
stack.removeArrangedSubview(moreButton)
|
||||
moreButton.removeFromSuperview()
|
||||
}
|
||||
|
||||
func finishPagination(hasMore: Bool) {
|
||||
hasMoreContent = hasMore
|
||||
loadingMore = false
|
||||
guard hasMore else { return }
|
||||
var configuration = moreButton.configuration
|
||||
configuration?.title = "Pull up or tap to load more"
|
||||
configuration?.showsActivityIndicator = false
|
||||
moreButton.configuration = configuration
|
||||
moreButton.accessibilityLabel = "Load more results"
|
||||
if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) }
|
||||
if moreButton.constraints.isEmpty {
|
||||
moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
func failPagination() {
|
||||
loadingMore = false
|
||||
finishPagination(hasMore: hasMoreContent)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
|
||||
let bottom = max(
|
||||
-scrollView.adjustedContentInset.top,
|
||||
scrollView.contentSize.height
|
||||
+ scrollView.adjustedContentInset.bottom
|
||||
- scrollView.bounds.height
|
||||
)
|
||||
if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
|
||||
}
|
||||
|
||||
private func requestMoreContent() {
|
||||
guard hasMoreContent, !loadingMore else { return }
|
||||
loadingMore = true
|
||||
var configuration = moreButton.configuration
|
||||
configuration?.title = "Loading more…"
|
||||
configuration?.showsActivityIndicator = true
|
||||
moreButton.configuration = configuration
|
||||
loadMoreContent()
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
loadContent(refreshing: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class IssueViewController: MarkdownPageViewController {
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let number: Int64
|
||||
private var page: IssuePage?
|
||||
private var currentPage: UInt32 = 0
|
||||
|
||||
init(context: AppContext, owner: String, repository: String, number: Int64) {
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.number = number
|
||||
super.init(context: context)
|
||||
title = "Issue"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
let addComment = UIBarButtonItem(
|
||||
image: context.symbol("plus.bubble"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(addComment)
|
||||
)
|
||||
addComment.accessibilityLabel = "Add comment"
|
||||
let editIssue = UIBarButtonItem(
|
||||
image: context.symbol("pencil"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(editIssue)
|
||||
)
|
||||
editIssue.accessibilityLabel = "Edit issue"
|
||||
navigationItem.rightBarButtonItems = [addComment, editIssue]
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
@objc private func addComment() { presentCommentEditor() }
|
||||
|
||||
private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) }
|
||||
|
||||
private func presentCommentEditor(_ comment: CommentRow? = nil) {
|
||||
let editor = CommentEditorViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number,
|
||||
comment: comment
|
||||
) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.dismiss(animated: true) { self.loadContent(refreshing: false) }
|
||||
}
|
||||
present(UINavigationController(rootViewController: editor), animated: true)
|
||||
}
|
||||
|
||||
@objc private func editIssue() {
|
||||
let editor = IssueEditorViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number
|
||||
) { [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.issue(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number,
|
||||
page: requestedPage
|
||||
)
|
||||
if requestedPage == 1 {
|
||||
page = result
|
||||
} else {
|
||||
page?.comments.append(contentsOf: result.comments)
|
||||
page?.hasMore = result.hasMore
|
||||
}
|
||||
currentPage = requestedPage
|
||||
if let page {
|
||||
replaceContent(detailViews(
|
||||
title: page.title,
|
||||
state: page.state,
|
||||
stateSubject: "issue",
|
||||
meta: page.meta,
|
||||
body: page.body,
|
||||
comments: page.comments,
|
||||
editComment: { [weak self] comment in self?.editComment(comment) },
|
||||
milestone: page.milestone,
|
||||
labels: page.labels
|
||||
))
|
||||
finishPagination(hasMore: page.hasMore)
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1 { endLoading() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PullViewController: MarkdownPageViewController {
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let number: Int64
|
||||
private var page: PullPage?
|
||||
private var currentPage: UInt32 = 0
|
||||
|
||||
init(context: AppContext, owner: String, repository: String, number: Int64) {
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.number = number
|
||||
super.init(context: context)
|
||||
title = "Pull Request"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
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.pull(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number,
|
||||
page: requestedPage
|
||||
)
|
||||
if requestedPage == 1 {
|
||||
page = result
|
||||
} else {
|
||||
page?.files.append(contentsOf: result.files)
|
||||
page?.comments.append(contentsOf: result.comments)
|
||||
page?.hasMore = result.hasMore
|
||||
}
|
||||
currentPage = requestedPage
|
||||
if let page {
|
||||
var views = detailViews(
|
||||
title: page.title,
|
||||
state: page.state,
|
||||
stateSubject: "pull request",
|
||||
meta: page.meta,
|
||||
body: page.body,
|
||||
comments: []
|
||||
)
|
||||
if !page.files.isEmpty {
|
||||
views.append(sectionLabel(page.filesRef))
|
||||
views.append(FileListView(rows: page.files) { [weak self] file in
|
||||
guard let self else { return }
|
||||
self.navigationController?.pushViewController(
|
||||
DiffViewController(
|
||||
context: self.context,
|
||||
source: .pull(
|
||||
owner: self.owner,
|
||||
repository: self.repository,
|
||||
number: self.number,
|
||||
path: file.path
|
||||
)
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
})
|
||||
}
|
||||
views.append(contentsOf: commentViews(page.comments))
|
||||
replaceContent(views)
|
||||
finishPagination(hasMore: page.hasMore)
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1 { endLoading() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class FileListView: UITableView, UITableViewDataSource, UITableViewDelegate {
|
||||
private let rows: [FileRow]
|
||||
private let select: (FileRow) -> Void
|
||||
private var contentHeight: NSLayoutConstraint!
|
||||
|
||||
init(rows: [FileRow], select: @escaping (FileRow) -> Void) {
|
||||
self.rows = rows
|
||||
self.select = select
|
||||
super.init(frame: .zero, style: .plain)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
dataSource = self
|
||||
delegate = self
|
||||
isScrollEnabled = false
|
||||
rowHeight = UITableView.automaticDimension
|
||||
estimatedRowHeight = 55
|
||||
separatorInset = .zero
|
||||
backgroundColor = .systemGroupedBackground
|
||||
contentHeight = heightAnchor.constraint(equalToConstant: CGFloat(rows.count) * estimatedRowHeight)
|
||||
contentHeight.isActive = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if abs(contentHeight.constant - contentSize.height) > 0.5 {
|
||||
contentHeight.constant = contentSize.height
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
rows.count
|
||||
}
|
||||
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "file")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
|
||||
let row = rows[indexPath.row]
|
||||
configureTextCell(cell, title: row.path, detail: row.status)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
select(rows[indexPath.row])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func detailViews(
|
||||
title: String,
|
||||
state: WorkItemState? = nil,
|
||||
stateSubject: String = "",
|
||||
meta: String,
|
||||
body: String,
|
||||
comments: [CommentRow],
|
||||
editComment: ((CommentRow) -> Void)? = nil,
|
||||
milestone: String = "",
|
||||
labels: [LabelRow] = []
|
||||
) -> [UIView] {
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .preferredFont(forTextStyle: .title1)
|
||||
titleLabel.numberOfLines = 0
|
||||
let metaLabel = UILabel()
|
||||
metaLabel.text = meta
|
||||
metaLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
metaLabel.textColor = .secondaryLabel
|
||||
metaLabel.numberOfLines = 0
|
||||
let bodyView = markdownView(body)
|
||||
var views: [UIView] = [
|
||||
state.map { stateTitle(titleLabel, state: $0, subject: stateSubject) } ?? titleLabel,
|
||||
metaLabel,
|
||||
]
|
||||
if !milestone.isEmpty {
|
||||
let milestoneLabel = UILabel()
|
||||
milestoneLabel.attributedText = symbolText(
|
||||
"flag.fill",
|
||||
text: milestone,
|
||||
font: .preferredFont(forTextStyle: .subheadline),
|
||||
color: .secondaryLabel
|
||||
)
|
||||
milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
|
||||
views.append(milestoneLabel)
|
||||
}
|
||||
if !labels.isEmpty { views.append(IssueLabelsView(labels)) }
|
||||
return views + [separator(), bodyView] + commentViews(comments, editComment: editComment)
|
||||
}
|
||||
|
||||
private func stateTitle(_ titleLabel: UILabel, state: WorkItemState, subject: String) -> UIView {
|
||||
let icon = UIImageView()
|
||||
configureOpenClosedStateIcon(icon, state: state, subject: subject, textStyle: .title1)
|
||||
let stack = UIStackView(arrangedSubviews: [icon, titleLabel])
|
||||
stack.axis = .horizontal
|
||||
stack.alignment = .firstBaseline
|
||||
stack.spacing = 8
|
||||
return stack
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func commentViews(
|
||||
_ comments: [CommentRow],
|
||||
editComment: ((CommentRow) -> Void)? = nil
|
||||
) -> [UIView] {
|
||||
guard !comments.isEmpty else { return [] }
|
||||
var views: [UIView] = [sectionLabel("Comments")]
|
||||
for comment in comments {
|
||||
let author = UILabel()
|
||||
author.text = comment.author
|
||||
author.font = .preferredFont(forTextStyle: .headline)
|
||||
var headerViews: [UIView] = [author, UIView()]
|
||||
if comment.canEdit, let editComment {
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.image = UIImage(systemName: "pencil")
|
||||
configuration.contentInsets = .zero
|
||||
let button = UIButton(
|
||||
configuration: configuration,
|
||||
primaryAction: UIAction { _ in editComment(comment) }
|
||||
)
|
||||
button.accessibilityLabel = "Edit comment by \(comment.author)"
|
||||
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
button.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
headerViews.append(button)
|
||||
}
|
||||
let header = UIStackView(arrangedSubviews: headerViews)
|
||||
header.alignment = .center
|
||||
let date = UILabel()
|
||||
date.text = comment.meta
|
||||
date.font = .preferredFont(forTextStyle: .caption1)
|
||||
date.textColor = .tertiaryLabel
|
||||
let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 6
|
||||
views.append(stack)
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
private func sectionLabel(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
return label
|
||||
}
|
||||
Reference in New Issue
Block a user