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