506 lines
19 KiB
Swift
506 lines
19 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] = []
|
|
|
|
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) {
|
|
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]
|
|
private let calendar = Calendar(identifier: .gregorian)
|
|
|
|
init(page: HomePage) {
|
|
let latest = page.heatCells.last.map {
|
|
Date(timeIntervalSince1970: TimeInterval($0.timestamp))
|
|
} ?? Date()
|
|
let latestMonth = Calendar(identifier: .gregorian).date(
|
|
from: Calendar(identifier: .gregorian).dateComponents([.year, .month], from: latest)
|
|
) ?? latest
|
|
let firstMonth = Calendar(identifier: .gregorian).date(
|
|
byAdding: .month,
|
|
value: -8,
|
|
to: latestMonth
|
|
) ?? latestMonth
|
|
cells = page.heatCells.filter {
|
|
Date(timeIntervalSince1970: TimeInterval($0.timestamp)) >= firstMonth
|
|
}
|
|
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 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 = "\(cells.reduce(0) { $0 + $1.contributions }) 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) {
|
|
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 width = max(4, min(6, (bounds.width - 32) / weekCount - 1))
|
|
let gap = width + 1
|
|
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: 16 + 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: 16 + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
|
|
y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
|
|
width: width,
|
|
height: width
|
|
),
|
|
cornerRadius: 1
|
|
).fill()
|
|
}
|
|
}
|
|
}
|