134
ios/Sources/AppContext.swift
Normal file
134
ios/Sources/AppContext.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class AppContext {
|
||||
let core = GotchaCore()
|
||||
private let window: UIWindow
|
||||
private(set) var tabs = UITabBarController()
|
||||
private(set) var navigationControllers: [UINavigationController] = []
|
||||
|
||||
init(window: UIWindow) {
|
||||
self.window = window
|
||||
applyAppearance()
|
||||
}
|
||||
|
||||
func makeRootController() -> UIViewController {
|
||||
let roots: [UIViewController] = [
|
||||
HomeViewController(context: self),
|
||||
repositoryRoot(mode: .issues),
|
||||
repositoryRoot(mode: .commits),
|
||||
PullsViewController(context: self),
|
||||
repositoryRoot(mode: .milestones),
|
||||
]
|
||||
let items = [
|
||||
("Home", "house", "house.fill"),
|
||||
("Issues", "exclamationmark.circle", "exclamationmark.circle.fill"),
|
||||
("Repos", "books.vertical", "books.vertical.fill"),
|
||||
("PRs", "arrow.triangle.pull", "arrow.triangle.pull"),
|
||||
("Milestones", "flag", "flag.fill"),
|
||||
]
|
||||
navigationControllers = zip(roots, items).map { root, item in
|
||||
let navigation = UINavigationController(rootViewController: root)
|
||||
navigation.tabBarItem = UITabBarItem(
|
||||
title: item.0,
|
||||
image: UIImage(systemName: item.1),
|
||||
selectedImage: UIImage(systemName: item.2)
|
||||
)
|
||||
return navigation
|
||||
}
|
||||
tabs.viewControllers = navigationControllers
|
||||
return tabs
|
||||
}
|
||||
|
||||
func showStartupErrorIfNeeded() {
|
||||
guard let message = core.startupError() else { return }
|
||||
tabs.present(errorAlert(message), animated: true)
|
||||
}
|
||||
|
||||
func selectServer(index: UInt32) throws {
|
||||
try core.selectServer(index: index)
|
||||
let replacements: [(Int, UIViewController)] = [
|
||||
(0, HomeViewController(context: self)),
|
||||
(1, RepositoriesViewController(context: self, mode: .issues)),
|
||||
(2, RepositoriesViewController(context: self, mode: .commits)),
|
||||
(3, PullsViewController(context: self)),
|
||||
(4, RepositoriesViewController(context: self, mode: .milestones)),
|
||||
]
|
||||
for (index, root) in replacements {
|
||||
navigationControllers[index].setViewControllers([root], animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
func didAddServer(index: UInt32) throws {
|
||||
try selectServer(index: index)
|
||||
}
|
||||
|
||||
func applyAppearance() {
|
||||
switch core.settings().appearance {
|
||||
case 1: window.overrideUserInterfaceStyle = .light
|
||||
case 2: window.overrideUserInterfaceStyle = .dark
|
||||
default: window.overrideUserInterfaceStyle = .unspecified
|
||||
}
|
||||
}
|
||||
|
||||
func route(_ activity: ActivityRow) {
|
||||
switch activity.target {
|
||||
case "repository":
|
||||
tabs.selectedIndex = 1
|
||||
navigationControllers[1].pushViewController(
|
||||
IssuesViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
case "issue":
|
||||
tabs.selectedIndex = 1
|
||||
navigationControllers[1].pushViewController(
|
||||
IssueViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository,
|
||||
number: activity.number
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
case "pull":
|
||||
tabs.selectedIndex = 3
|
||||
navigationControllers[3].pushViewController(
|
||||
PullViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository,
|
||||
number: activity.number
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
case "commit":
|
||||
tabs.selectedIndex = 2
|
||||
navigationControllers[2].pushViewController(
|
||||
FilesViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository,
|
||||
sha: activity.sha
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func symbol(_ name: String) -> UIImage? {
|
||||
UIImage(systemName: name)
|
||||
}
|
||||
|
||||
private func repositoryRoot(mode: RepositoryMode) -> UIViewController {
|
||||
if core.activeServerIndex() == nil {
|
||||
return ServersViewController(context: self, destination: mode)
|
||||
}
|
||||
return RepositoriesViewController(context: self, mode: mode)
|
||||
}
|
||||
}
|
||||
19
ios/Sources/AppDelegate.swift
Normal file
19
ios/Sources/AppDelegate.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
let context = AppContext(window: window)
|
||||
window.rootViewController = context.makeRootController()
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
context.showStartupErrorIfNeeded()
|
||||
return true
|
||||
}
|
||||
}
|
||||
654
ios/Sources/ContentScreens.swift
Normal file
654
ios/Sources/ContentScreens.swift
Normal file
@@ -0,0 +1,654 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class IssuesViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private var rows: [IssueRow] = []
|
||||
|
||||
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(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||
updateFilterMenu()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
rows = try await context.core.issues(owner: owner, repository: repository)
|
||||
tableView.reloadData()
|
||||
let status = context.core.settings().issueStatus
|
||||
tableView.backgroundView = rows.isEmpty
|
||||
? EmptyBackgroundView(
|
||||
title: "No \(status) issues",
|
||||
detail: "No issues match the selected status."
|
||||
)
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
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: "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
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: context.symbol("line.3.horizontal.decrease.circle"),
|
||||
menu: UIMenu(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.loadContent(refreshing: false)
|
||||
} catch {
|
||||
self.show(error: error)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class IssueCell: UITableViewCell {
|
||||
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
|
||||
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: [titleLabel, 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) {
|
||||
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) {
|
||||
let view = UILabel()
|
||||
view.text = " \(label.name) "
|
||||
view.font = .preferredFont(forTextStyle: .caption2)
|
||||
view.textColor = label.light ? .black : .white
|
||||
view.backgroundColor = UIColor(hex: label.color)
|
||||
view.layer.cornerRadius = 9
|
||||
view.clipsToBounds = true
|
||||
labels.addArrangedSubview(view)
|
||||
}
|
||||
labels.addArrangedSubview(UIView())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MilestonesViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private var rows: [MilestoneRow] = []
|
||||
|
||||
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
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
rows = try await context.core.milestones(owner: owner, repository: repository)
|
||||
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) }
|
||||
}
|
||||
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?
|
||||
|
||||
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.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 118
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
page = try await context.core.milestone(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
id: id
|
||||
)
|
||||
title = page?.milestone.title
|
||||
tableView.reloadData()
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
endLoading()
|
||||
}
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 1 : page?.issues.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 1 ? "Issues" : nil
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
section == 1 && page?.issues.isEmpty == true ? "No issues are assigned to this milestone." : 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
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "issue", for: indexPath) as! IssueCell
|
||||
cell.configure(page.issues[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 1, let issue = page?.issues[indexPath.row] else { return }
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
navigationController?.pushViewController(
|
||||
IssueViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: issue.number
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class MilestoneCell: UITableViewCell {
|
||||
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
|
||||
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: [titleLabel, 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
|
||||
titleLabel.text = row.title
|
||||
descriptionLabel.text = row.description
|
||||
metaLabel.text = row.meta
|
||||
let total = row.openIssues + row.closedIssues
|
||||
progress.progress = total == 0 ? 0 : Float(row.closedIssues) / Float(total)
|
||||
progress.trackTintColor = total == 0 ? .systemGray5 : .systemOrange
|
||||
progress.accessibilityLabel = "Milestone progress"
|
||||
progress.accessibilityValue = "\(row.closedIssues) closed, \(row.openIssues) open"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PullsViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private var rows: [PullRow] = []
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init()
|
||||
title = "Pull Requests"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
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
|
||||
}
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
rows = try await context.core.pulls()
|
||||
tableView.reloadData()
|
||||
let status = context.core.settings().pullStatus
|
||||
tableView.backgroundView = rows.isEmpty
|
||||
? EmptyBackgroundView(
|
||||
title: "No \(status) pull requests",
|
||||
detail: "No pull requests match the selected status."
|
||||
)
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
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: "pull")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "pull")
|
||||
let row = rows[indexPath.row]
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: "\(row.repository) #\(row.number)\n\(row.title)",
|
||||
detail: "\(row.summary)\n\(row.meta)"
|
||||
)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
116
|
||||
}
|
||||
|
||||
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
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: context.symbol("line.3.horizontal.decrease.circle"),
|
||||
menu: UIMenu(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.loadContent(refreshing: false)
|
||||
} catch {
|
||||
self.show(error: error)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CommitsViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private var page: CommitPage?
|
||||
private var branch: String?
|
||||
|
||||
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")
|
||||
updateBranchMenu()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
page = try await context.core.commits(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
branch: branch
|
||||
)
|
||||
updateBranchMenu()
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = page?.commits.isEmpty == true
|
||||
? EmptyBackgroundView(title: "No commits", detail: "This repository has no commit history.")
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
endLoading()
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
page?.commits.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "commit", for: indexPath) as! CommitCell
|
||||
if let page { cell.configure(page.commits[indexPath.row], laneCount: page.laneCount) }
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
86
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let row = page?.commits[indexPath.row] else { return }
|
||||
navigationController?.pushViewController(
|
||||
FilesViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
sha: row.sha
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
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 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 : min(72, CGFloat(laneCount) * 10 + 8)
|
||||
content.text = row.title
|
||||
content.secondaryText = row.refs.isEmpty
|
||||
? "\(row.meta)\n\(row.sha.prefix(8))"
|
||||
: "\(row.refs)\n\(row.meta) · \(row.sha.prefix(8))"
|
||||
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 spacing: CGFloat = 10
|
||||
let centerY = bounds.midY
|
||||
for lane in 0..<laneCount {
|
||||
let x = 10 + CGFloat(lane) * spacing
|
||||
let color = laneColor(lane)
|
||||
context.setStrokeColor(color.cgColor)
|
||||
context.setLineWidth(3)
|
||||
if row.topLanes.contains(lane) {
|
||||
context.move(to: CGPoint(x: x, y: 0))
|
||||
context.addLine(to: CGPoint(x: x, y: centerY))
|
||||
context.strokePath()
|
||||
}
|
||||
if row.bottomLanes.contains(lane) {
|
||||
context.move(to: CGPoint(x: x, y: centerY))
|
||||
context.addLine(to: CGPoint(x: x, y: bounds.height))
|
||||
context.strokePath()
|
||||
}
|
||||
if row.connections.contains(lane), lane > 0 {
|
||||
context.move(to: CGPoint(x: x - spacing, y: centerY))
|
||||
context.addLine(to: CGPoint(x: x, y: centerY))
|
||||
context.strokePath()
|
||||
}
|
||||
if row.nodeLane == lane {
|
||||
context.setFillColor(color.cgColor)
|
||||
context.fillEllipse(in: CGRect(x: x - 5, y: centerY - 5, width: 10, height: 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
453
ios/Sources/DetailScreens.swift
Normal file
453
ios/Sources/DetailScreens.swift
Normal file
@@ -0,0 +1,453 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
class MarkdownPageViewController: UIViewController {
|
||||
let context: AppContext
|
||||
let scrollView = UIScrollView()
|
||||
let stack = UIStackView()
|
||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||
var loadingTask: Task<Void, Never>?
|
||||
|
||||
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.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 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)
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
loadContent(refreshing: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class IssueViewController: MarkdownPageViewController {
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let number: Int64
|
||||
|
||||
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()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let page = try await context.core.issue(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number
|
||||
)
|
||||
replaceContent(detailViews(
|
||||
title: page.title,
|
||||
meta: page.meta,
|
||||
body: page.body,
|
||||
comments: page.comments,
|
||||
milestone: page.milestone
|
||||
))
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
endLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PullViewController: MarkdownPageViewController {
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let number: Int64
|
||||
|
||||
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) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let page = try await context.core.pull(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number
|
||||
)
|
||||
var views = detailViews(
|
||||
title: page.title,
|
||||
meta: page.meta,
|
||||
body: page.body,
|
||||
comments: []
|
||||
)
|
||||
if !page.files.isEmpty {
|
||||
views.append(sectionLabel(page.filesRef))
|
||||
views.append(contentsOf: page.files.map { file in
|
||||
detailButton(title: file.path, detail: file.status) { [weak self] 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)
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
endLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FilesViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let sha: String
|
||||
private var rows: [FileRow] = []
|
||||
|
||||
init(context: AppContext, owner: String, repository: String, sha: String) {
|
||||
self.context = context
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.sha = sha
|
||||
super.init()
|
||||
title = "Changed Files"
|
||||
}
|
||||
|
||||
@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) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
rows = try await context.core.commitFiles(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
sha: sha
|
||||
)
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = rows.isEmpty
|
||||
? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.")
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
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: "file")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "file")
|
||||
let row = rows[indexPath.row]
|
||||
configureTextCell(cell, title: row.path, detail: row.status)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let row = rows[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
DiffViewController(
|
||||
context: context,
|
||||
source: .commit(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
sha: sha,
|
||||
path: row.path
|
||||
)
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum DiffSource {
|
||||
case commit(owner: String, repository: String, sha: String, path: String)
|
||||
case pull(owner: String, repository: String, number: Int64, path: String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DiffViewController: UIViewController {
|
||||
private let context: AppContext
|
||||
private let source: DiffSource
|
||||
private let textView = UITextView()
|
||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||
private var loadingTask: Task<Void, Never>?
|
||||
|
||||
init(context: AppContext, source: DiffSource) {
|
||||
self.context = context
|
||||
self.source = source
|
||||
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 = .systemBackground
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = true
|
||||
textView.alwaysBounceVertical = true
|
||||
textView.alwaysBounceHorizontal = true
|
||||
textView.showsHorizontalScrollIndicator = true
|
||||
textView.textContainer.widthTracksTextView = false
|
||||
textView.textContainer.lineFragmentPadding = 8
|
||||
textView.refreshControl = UIRefreshControl()
|
||||
textView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged)
|
||||
view.addSubview(textView)
|
||||
NSLayoutConstraint.activate([
|
||||
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
textView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
beginNavigationLoading(spinner)
|
||||
reload()
|
||||
}
|
||||
|
||||
deinit { loadingTask?.cancel() }
|
||||
|
||||
@objc private func reload() {
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let page: DiffPage
|
||||
switch source {
|
||||
case let .commit(owner, repository, sha, path):
|
||||
page = try await context.core.commitDiff(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
sha: sha,
|
||||
path: path
|
||||
)
|
||||
case let .pull(owner, repository, number, path):
|
||||
page = try await context.core.pullDiff(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number,
|
||||
path: path
|
||||
)
|
||||
}
|
||||
title = page.title
|
||||
textView.attributedText = diffText(page)
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
endNavigationLoading(spinner)
|
||||
textView.refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func diffText(_ page: DiffPage) -> NSAttributedString {
|
||||
let output = NSMutableAttributedString()
|
||||
let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
for line in page.lines {
|
||||
let text = String(format: "%4@ %4@ %@\n", line.oldNumber, line.newNumber, line.text)
|
||||
let color: UIColor
|
||||
switch line.kind {
|
||||
case "addition": color = UIColor.systemGreen.withAlphaComponent(0.16)
|
||||
case "removal": color = UIColor.systemRed.withAlphaComponent(0.16)
|
||||
case "hunk": color = UIColor.systemBlue.withAlphaComponent(0.14)
|
||||
case "header": color = UIColor.systemGray.withAlphaComponent(0.14)
|
||||
default: color = .clear
|
||||
}
|
||||
output.append(NSAttributedString(string: text, attributes: [
|
||||
.font: font,
|
||||
.foregroundColor: UIColor.label,
|
||||
.backgroundColor: color,
|
||||
]))
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private func detailViews(
|
||||
title: String,
|
||||
meta: String,
|
||||
body: String,
|
||||
comments: [CommentRow],
|
||||
milestone: String = ""
|
||||
) -> [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] = [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)
|
||||
}
|
||||
return views + [separator(), bodyView] + commentViews(comments)
|
||||
}
|
||||
|
||||
private func commentViews(_ comments: [CommentRow]) -> [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)
|
||||
let date = UILabel()
|
||||
date.text = comment.meta
|
||||
date.font = .preferredFont(forTextStyle: .caption1)
|
||||
date.textColor = .tertiaryLabel
|
||||
let stack = UIStackView(arrangedSubviews: [author, markdownView(comment.body), date, separator()])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 6
|
||||
views.append(stack)
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
private func markdownView(_ source: String) -> UITextView {
|
||||
let view = UITextView()
|
||||
view.attributedText = markdown(source)
|
||||
view.isEditable = false
|
||||
view.isSelectable = true
|
||||
view.isScrollEnabled = false
|
||||
view.backgroundColor = .clear
|
||||
view.textContainerInset = .zero
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
return view
|
||||
}
|
||||
|
||||
private func sectionLabel(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
return label
|
||||
}
|
||||
|
||||
private func separator() -> UIView {
|
||||
let line = UIView()
|
||||
line.backgroundColor = .separator
|
||||
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||
return line
|
||||
}
|
||||
|
||||
private func detailButton(title: String, detail: String, action: @escaping () -> Void) -> UIButton {
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = title
|
||||
configuration.subtitle = detail
|
||||
configuration.image = UIImage(systemName: "chevron.right")
|
||||
configuration.imagePlacement = .trailing
|
||||
configuration.imagePadding = 8
|
||||
configuration.contentInsets = .init(top: 10, leading: 0, bottom: 10, trailing: 0)
|
||||
let button = UIButton(configuration: configuration, primaryAction: UIAction { _ in action() })
|
||||
button.contentHorizontalAlignment = .fill
|
||||
return button
|
||||
}
|
||||
454
ios/Sources/ListScreens.swift
Normal file
454
ios/Sources/ListScreens.swift
Normal file
@@ -0,0 +1,454 @@
|
||||
import UIKit
|
||||
|
||||
enum RepositoryMode {
|
||||
case issues
|
||||
case commits
|
||||
case milestones
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ServersViewController: UITableViewController {
|
||||
private let context: AppContext
|
||||
private let destination: RepositoryMode
|
||||
private var servers: [ServerRow] = []
|
||||
|
||||
init(context: AppContext, destination: RepositoryMode) {
|
||||
self.context = context
|
||||
self.destination = destination
|
||||
super.init(style: .plain)
|
||||
title = "Servers"
|
||||
tableView.backgroundColor = .systemGroupedBackground
|
||||
tableView.separatorInset = .zero
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
servers = context.core.servers()
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = servers.isEmpty
|
||||
? EmptyBackgroundView(title: "No servers", detail: "Add a Gitea server to get started.")
|
||||
: nil
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
systemItem: .add,
|
||||
primaryAction: UIAction { [weak self] _ in self?.showAddServer() }
|
||||
)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
servers.count
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "server")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "server")
|
||||
let server = servers[indexPath.row]
|
||||
configureTextCell(cell, title: server.name, detail: server.url)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
do {
|
||||
try context.selectServer(index: UInt32(indexPath.row))
|
||||
navigationController?.setViewControllers([
|
||||
RepositoriesViewController(context: context, mode: destination),
|
||||
], animated: true)
|
||||
} catch {
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func showAddServer() {
|
||||
let controller = AddServerViewController(context: context) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.servers = self.context.core.servers()
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
present(UINavigationController(rootViewController: controller), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AddServerViewController: UITableViewController, UITextFieldDelegate {
|
||||
private let context: AppContext
|
||||
private let completion: () -> Void
|
||||
private let nameField = UITextField()
|
||||
private let urlField = UITextField()
|
||||
private let tokenField = UITextField()
|
||||
private var saveButton: UIBarButtonItem!
|
||||
|
||||
init(context: AppContext, completion: @escaping () -> Void) {
|
||||
self.context = context
|
||||
self.completion = completion
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Add Server"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
systemItem: .cancel,
|
||||
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
|
||||
)
|
||||
saveButton = UIBarButtonItem(
|
||||
title: "Add",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
navigationItem.rightBarButtonItem = saveButton
|
||||
configure(nameField, placeholder: "Work", contentType: .name)
|
||||
configure(urlField, placeholder: "https://gitea.example.com", contentType: .URL)
|
||||
urlField.keyboardType = .URL
|
||||
urlField.autocapitalizationType = .none
|
||||
configure(tokenField, placeholder: "Access token", contentType: nil)
|
||||
tokenField.isSecureTextEntry = true
|
||||
tokenField.autocapitalizationType = .none
|
||||
tokenField.returnKeyType = .done
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
["Name", "Server URL", "Access token"][section]
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
let field = [nameField, urlField, tokenField][indexPath.section]
|
||||
field.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.contentView.addSubview(field)
|
||||
NSLayoutConstraint.activate([
|
||||
field.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
|
||||
field.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
|
||||
field.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
|
||||
field.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
|
||||
cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
|
||||
])
|
||||
return cell
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
if textField === nameField { urlField.becomeFirstResponder() }
|
||||
else if textField === urlField { tokenField.becomeFirstResponder() }
|
||||
else { save() }
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
view.endEditing(true)
|
||||
saveButton.isEnabled = false
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.startAnimating()
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||
Task {
|
||||
do {
|
||||
let index = try await context.core.addServer(
|
||||
name: nameField.text ?? "",
|
||||
url: urlField.text ?? "",
|
||||
token: tokenField.text ?? ""
|
||||
)
|
||||
try context.didAddServer(index: index)
|
||||
completion()
|
||||
dismiss(animated: true)
|
||||
} catch {
|
||||
navigationItem.rightBarButtonItem = saveButton
|
||||
saveButton.isEnabled = true
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configure(
|
||||
_ field: UITextField,
|
||||
placeholder: String,
|
||||
contentType: UITextContentType?
|
||||
) {
|
||||
field.placeholder = placeholder
|
||||
field.textContentType = contentType
|
||||
field.clearButtonMode = .whileEditing
|
||||
field.delegate = self
|
||||
field.returnKeyType = .next
|
||||
field.adjustsFontForContentSizeCategory = true
|
||||
field.font = .preferredFont(forTextStyle: .body)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class RepositoriesViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let mode: RepositoryMode
|
||||
private var rows: [RepositoryRow] = []
|
||||
|
||||
init(context: AppContext, mode: RepositoryMode) {
|
||||
self.context = context
|
||||
self.mode = mode
|
||||
super.init()
|
||||
title = context.core.activeServerName() ?? "Repositories"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
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, destination: self.mode),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
)
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
rows = try await context.core.repositories()
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = rows.isEmpty
|
||||
? EmptyBackgroundView(
|
||||
title: "No repositories",
|
||||
detail: "This account does not own any repositories on this server."
|
||||
)
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
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: "repository")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository")
|
||||
let row = rows[indexPath.row]
|
||||
configureTextCell(cell, title: row.name, detail: "\(row.description)\n\(row.meta)")
|
||||
let button = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in
|
||||
self?.toggleFavorite(row)
|
||||
})
|
||||
button.setImage(context.symbol(row.favorite ? "star.fill" : "star"), for: .normal)
|
||||
button.tintColor = row.favorite ? .systemYellow : .tertiaryLabel
|
||||
button.frame.size = CGSize(width: 44, height: 44)
|
||||
cell.accessoryView = button
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
96
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let row = rows[indexPath.row]
|
||||
let destination: UIViewController
|
||||
switch mode {
|
||||
case .issues:
|
||||
destination = IssuesViewController(
|
||||
context: context,
|
||||
owner: row.owner,
|
||||
repository: row.name
|
||||
)
|
||||
case .commits:
|
||||
destination = CommitsViewController(
|
||||
context: context,
|
||||
owner: row.owner,
|
||||
repository: row.name
|
||||
)
|
||||
case .milestones:
|
||||
destination = MilestonesViewController(
|
||||
context: context,
|
||||
owner: row.owner,
|
||||
repository: row.name
|
||||
)
|
||||
}
|
||||
navigationController?.pushViewController(destination, animated: true)
|
||||
}
|
||||
|
||||
private func toggleFavorite(_ row: RepositoryRow) {
|
||||
do {
|
||||
rows = try context.core.toggleFavorite(owner: row.owner, repository: row.name)
|
||||
tableView.reloadData()
|
||||
} catch {
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class HomeViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private var page: HomePage?
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init()
|
||||
title = "Home"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
let settings = UIBarButtonItem(
|
||||
image: context.symbol("gearshape"),
|
||||
primaryAction: UIAction { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.navigationController?.pushViewController(
|
||||
SettingsViewController(context: self.context),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
)
|
||||
settings.accessibilityLabel = "Settings"
|
||||
navigationItem.rightBarButtonItem = settings
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
guard page == nil else { return }
|
||||
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
|
||||
}
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
page = try await context.core.home()
|
||||
title = page?.serverName
|
||||
tableView.tableHeaderView = page.map { HeatmapView(page: $0) }
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
endLoading()
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
page?.activities.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
|
||||
guard let row = page?.activities[indexPath.row] else { return cell }
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: row.title,
|
||||
detail: "\(row.detail)\n\(row.meta)",
|
||||
image: context.symbol(symbolName(for: row.icon))
|
||||
)
|
||||
cell.accessoryType = row.target.isEmpty ? .none : .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
92
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if let row = page?.activities[indexPath.row] { context.route(row) }
|
||||
}
|
||||
|
||||
private func symbolName(for icon: String) -> String {
|
||||
switch icon {
|
||||
case let value where value.contains("pull"): return "arrow.triangle.pull"
|
||||
case let value where value.contains("issue"): return "exclamationmark.circle"
|
||||
case let value where value.contains("branch"): return "arrow.triangle.branch"
|
||||
case let value where value.contains("tag"): return "tag"
|
||||
case "push": return "arrow.up.circle"
|
||||
case "release": return "shippingbox"
|
||||
default: return "books.vertical"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class HeatmapView: UIView {
|
||||
private let cells: [HeatCell]
|
||||
|
||||
init(page: HomePage) {
|
||||
cells = page.heatCells
|
||||
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 132))
|
||||
backgroundColor = .systemBackground
|
||||
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
|
||||
title.text = "Activity · last 12 months"
|
||||
title.font = .preferredFont(forTextStyle: .subheadline)
|
||||
title.textColor = .secondaryLabel
|
||||
addSubview(title)
|
||||
let total = UILabel(frame: CGRect(x: 16, y: 104, width: 300, height: 18))
|
||||
total.text = "\(page.contributionCount) contributions"
|
||||
total.font = .preferredFont(forTextStyle: .caption1)
|
||||
total.textColor = .tertiaryLabel
|
||||
addSubview(total)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
let width = max(4, min(6, (bounds.width - 32) / 55))
|
||||
let gap = width + 1
|
||||
for cell in cells {
|
||||
let colors: [UIColor] = [
|
||||
.systemGray5,
|
||||
UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1),
|
||||
UIColor(red: 0.45, green: 0.71, blue: 0.91, alpha: 1),
|
||||
UIColor(red: 0.15, green: 0.55, blue: 0.83, alpha: 1),
|
||||
UIColor(red: 0.04, green: 0.41, blue: 0.72, alpha: 1),
|
||||
]
|
||||
colors[Int(min(cell.level, 4))].setFill()
|
||||
UIBezierPath(
|
||||
roundedRect: CGRect(
|
||||
x: 16 + CGFloat(cell.week) * gap,
|
||||
y: 43 + CGFloat(cell.day) * gap,
|
||||
width: width,
|
||||
height: width
|
||||
),
|
||||
cornerRadius: 1
|
||||
).fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
59
ios/Sources/SettingsViewController.swift
Normal file
59
ios/Sources/SettingsViewController.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class SettingsViewController: UITableViewController {
|
||||
private let context: AppContext
|
||||
private let appearanceControl = UISegmentedControl(items: ["Auto", "Light", "Dark"])
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Settings"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
let settings = context.core.settings()
|
||||
appearanceControl.selectedSegmentIndex = Int(settings.appearance)
|
||||
appearanceControl.addTarget(self, action: #selector(appearanceChanged), for: .valueChanged)
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
"Appearance"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
"Follow iOS automatically or choose a fixed appearance."
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
appearanceControl.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.contentView.addSubview(appearanceControl)
|
||||
NSLayoutConstraint.activate([
|
||||
appearanceControl.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
|
||||
appearanceControl.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
|
||||
appearanceControl.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8),
|
||||
appearanceControl.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8),
|
||||
])
|
||||
return cell
|
||||
}
|
||||
|
||||
@objc private func appearanceChanged() {
|
||||
do {
|
||||
try context.core.setAppearance(index: UInt32(appearanceControl.selectedSegmentIndex))
|
||||
context.applyAppearance()
|
||||
} catch {
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
140
ios/Sources/Support.swift
Normal file
140
ios/Sources/Support.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import UIKit
|
||||
|
||||
func errorAlert(_ message: String) -> UIAlertController {
|
||||
let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
return alert
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
func show(error: Error) {
|
||||
present(errorAlert(error.localizedDescription), animated: true)
|
||||
}
|
||||
|
||||
func beginNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
||||
guard navigationItem.rightBarButtonItem == nil else { return }
|
||||
spinner.startAnimating()
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
||||
}
|
||||
|
||||
func endNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
||||
spinner.stopAnimating()
|
||||
if navigationItem.rightBarButtonItem?.customView === spinner {
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class RefreshingTableViewController: UITableViewController {
|
||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||
var loadingTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
super.init(style: .plain)
|
||||
tableView.backgroundColor = .systemGroupedBackground
|
||||
tableView.separatorInset = .zero
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
deinit { loadingTask?.cancel() }
|
||||
|
||||
func loadContent(refreshing: Bool) {}
|
||||
|
||||
func beginLoading(refreshing: Bool) {
|
||||
if !refreshing { beginNavigationLoading(spinner) }
|
||||
}
|
||||
|
||||
func endLoading() {
|
||||
endNavigationLoading(spinner)
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
loadContent(refreshing: true)
|
||||
}
|
||||
}
|
||||
|
||||
final class EmptyBackgroundView: UIView {
|
||||
init(title: String, detail: String) {
|
||||
super.init(frame: .zero)
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
titleLabel.textAlignment = .center
|
||||
let detailLabel = UILabel()
|
||||
detailLabel.text = detail
|
||||
detailLabel.font = .preferredFont(forTextStyle: .body)
|
||||
detailLabel.textColor = .secondaryLabel
|
||||
detailLabel.textAlignment = .center
|
||||
detailLabel.numberOfLines = 0
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
|
||||
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
|
||||
stack.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -40),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
convenience init?(hex: String) {
|
||||
let value = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
guard value.count == 6, let rgb = Int(value, radix: 16) else { return nil }
|
||||
self.init(
|
||||
red: CGFloat((rgb >> 16) & 0xff) / 255,
|
||||
green: CGFloat((rgb >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(rgb & 0xff) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func markdown(_ source: String, textStyle: UIFont.TextStyle = .body) -> NSAttributedString {
|
||||
let attributed = (try? AttributedString(
|
||||
markdown: source,
|
||||
options: .init(interpretedSyntax: .full)
|
||||
)) ?? AttributedString(source)
|
||||
var mutable = attributed
|
||||
mutable.font = .preferredFont(forTextStyle: textStyle)
|
||||
mutable.foregroundColor = UIColor.label
|
||||
return NSAttributedString(mutable)
|
||||
}
|
||||
|
||||
func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, image: UIImage? = nil) {
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = title
|
||||
content.secondaryText = detail
|
||||
content.secondaryTextProperties.numberOfLines = 2
|
||||
content.image = image
|
||||
content.imageProperties.tintColor = .tintColor
|
||||
cell.contentConfiguration = content
|
||||
cell.backgroundColor = .systemBackground
|
||||
}
|
||||
|
||||
func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) -> NSAttributedString {
|
||||
let output = NSMutableAttributedString()
|
||||
if let image = UIImage(systemName: symbol)?.withTintColor(color, renderingMode: .alwaysOriginal) {
|
||||
let attachment = NSTextAttachment(image: image)
|
||||
attachment.bounds = CGRect(x: 0, y: -2, width: font.pointSize, height: font.pointSize)
|
||||
output.append(NSAttributedString(attachment: attachment))
|
||||
output.append(NSAttributedString(string: " "))
|
||||
}
|
||||
output.append(NSAttributedString(string: text, attributes: [
|
||||
.font: font,
|
||||
.foregroundColor: color,
|
||||
]))
|
||||
return output
|
||||
}
|
||||
Reference in New Issue
Block a user