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