Files
Gotcha/ios/Sources/ListScreens.swift
2026-07-31 09:52:24 +02:00

455 lines
16 KiB
Swift

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()
}
}
}