Files
Gotcha/ios/Sources/ListScreens.swift
2026-07-31 18:00:51 +02:00

611 lines
23 KiB
Swift

import UIKit
enum RepositoryMode {
case issues
case commits
case milestones
}
@MainActor
final class ServersViewController: UITableViewController {
private let context: AppContext
private var servers: [ServerRow] = []
init(context: AppContext) {
self.context = context
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))
} 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] = []
private var currentPage: UInt32 = 0
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),
animated: true
)
}
)
loadContent(refreshing: false)
}
override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
loadPage(currentPage + 1, refreshing: false)
}
private func loadPage(_ page: UInt32, refreshing: Bool) {
if page == 1 {
resetPagination()
beginLoading(refreshing: refreshing)
}
loadingTask?.cancel()
loadingTask = Task {
do {
let result = try await context.core.repositories(page: page)
rows = result.rows
currentPage = page
finishPagination(hasMore: result.hasMore)
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)
failPagination()
}
}
if page == 1 { 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 enum ActivityFilter: Int {
case all
case issues
case pulls
var coreValue: HomeActivityFilter {
switch self {
case .all: return .all
case .issues: return .issues
case .pulls: return .pullRequests
}
}
}
private let context: AppContext
private var page: HomePage?
private var filter = ActivityFilter.all
private var nextPage: UInt32?
private var activities: [ActivityRow] { page?.activities ?? [] }
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
}
loadPage(1, refreshing: refreshing)
}
override func loadMoreContent() {
guard let nextPage else { return }
loadPage(nextPage, 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.home(
page: requestedPage,
filter: filter.coreValue
)
if requestedPage == 1 {
page = result
} else {
page?.activities.append(contentsOf: result.activities)
page?.nextPage = result.nextPage
}
nextPage = result.nextPage
finishPagination(hasMore: result.nextPage != nil)
title = page?.serverName
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in
guard let self, let filter = ActivityFilter(rawValue: index) else { return }
guard filter != self.filter else { return }
self.filter = filter
self.loadPage(1, refreshing: false)
}
} }
updateActivities()
} catch {
if !Task.isCancelled {
show(error: error)
failPagination()
}
}
if requestedPage == 1 { endLoading() }
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
activities.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
let row = activities[indexPath.row]
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)
context.route(activities[indexPath.row])
}
private func updateActivities() {
tableView.reloadData()
tableView.backgroundView = activities.isEmpty
? EmptyBackgroundView(
title: "No matching activity",
detail: "This server has no recent activity of the selected type."
)
: nil
}
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]
private let calendar = Calendar(identifier: .gregorian)
init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) {
cells = page.heatCells
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
backgroundColor = .systemBackground
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
title.text = "Activity · last 9 months"
title.font = .preferredFont(forTextStyle: .subheadline)
title.textColor = .secondaryLabel
addSubview(title)
let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18))
total.text = "\(page.contributionCount) contributions"
total.font = .preferredFont(forTextStyle: .caption1)
total.textColor = .tertiaryLabel
addSubview(total)
let filters = UIStackView()
filters.axis = .horizontal
filters.spacing = 4
filters.translatesAutoresizingMaskIntoConstraints = false
let filterItems = [
("clock", "clock.fill", "All activity"),
("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"),
("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"),
]
for (index, item) in filterItems.enumerated() {
let button = UIButton(type: .custom, primaryAction: UIAction { action in
guard
let button = action.sender as? UIButton,
let stack = button.superview as? UIStackView
else { return }
for case let item as UIButton in stack.arrangedSubviews {
item.isSelected = item === button
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
}
onFilter(button.tag)
})
button.tag = index
button.setImage(UIImage(systemName: item.0), for: .normal)
button.setImage(UIImage(systemName: item.1), for: .selected)
button.isSelected = index == selectedFilter
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
button.accessibilityLabel = item.2
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
filters.addArrangedSubview(button)
}
addSubview(filters)
NSLayoutConstraint.activate([
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
filters.topAnchor.constraint(equalTo: topAnchor, constant: 132),
filters.heightAnchor.constraint(equalToConstant: 44),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func draw(_ rect: CGRect) {
guard
let first = cells.first,
let last = cells.last
else { return }
let firstDate = Date(timeIntervalSince1970: TimeInterval(first.timestamp))
let lastDate = Date(timeIntervalSince1970: TimeInterval(last.timestamp))
guard let firstWeek = calendar.dateInterval(of: .weekOfYear, for: firstDate)?.start else {
return
}
let monthFormatter = DateFormatter()
monthFormatter.dateFormat = "MMM"
let months = cells.reduce(into: [Date]()) { result, cell in
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
if result.last != month { result.append(month) }
}
let weekCount = CGFloat(
(calendar.dateComponents([.day], from: firstWeek, to: lastDate).day ?? 0) / 7 + 1
)
let monthGap: CGFloat = 4
let monthGaps = CGFloat(max(months.count - 1, 0)) * monthGap
let width = max(4, min(6, (bounds.width - 32 - monthGaps) / weekCount - 1))
let gap = width + 1
let graphWidth = (weekCount - 1) * gap + width + monthGaps
let graphOrigin = max(0, (bounds.width - graphWidth) / 2)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.preferredFont(forTextStyle: .caption2),
.foregroundColor: UIColor.secondaryLabel,
]
for (index, month) in months.enumerated() {
let days = calendar.dateComponents([.day], from: firstWeek, to: month).day ?? 0
monthFormatter.string(from: month).draw(
at: CGPoint(
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(index) * monthGap,
y: 34
),
withAttributes: labelAttributes
)
}
for cell in cells {
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
let days = calendar.dateComponents([.day], from: firstWeek, to: date).day ?? 0
let month = calendar.date(
from: calendar.dateComponents([.year, .month], from: date)
) ?? date
let monthIndex = months.firstIndex(of: month) ?? 0
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: graphOrigin + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
width: width,
height: width
),
cornerRadius: 1
).fill()
}
}
}