import UIKit func localDate(forUTCTimestamp timestamp: Int64) -> Date { var utc = Calendar(identifier: .gregorian) utc.timeZone = TimeZone(secondsFromGMT: 0)! let components = utc.dateComponents( [.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(timestamp)) ) return Calendar.current.date(from: components) ?? Date() } func utcTimestamp(forLocalDate date: Date) -> Int64 { let components = Calendar.current.dateComponents([.year, .month, .day], from: date) var utc = Calendar(identifier: .gregorian) utc.timeZone = TimeZone(secondsFromGMT: 0)! return Int64((utc.date(from: components) ?? date).timeIntervalSince1970) } func errorAlert(_ message: String) -> UIAlertController { let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) return alert } extension UIViewController { func show(error: Error) { present(errorAlert(error.localizedDescription), animated: true) } func beginNavigationLoading(_ spinner: UIActivityIndicatorView) { guard navigationItem.rightBarButtonItem == nil else { return } spinner.startAnimating() navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) } func endNavigationLoading(_ spinner: UIActivityIndicatorView) { spinner.stopAnimating() if navigationItem.rightBarButtonItem?.customView === spinner { navigationItem.rightBarButtonItem = nil } } } @MainActor class RefreshingTableViewController: UITableViewController { private let spinner = UIActivityIndicatorView(style: .medium) var loadingTask: Task? private lazy var moreButton = UIButton( configuration: .plain(), primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() } ) private var hasMoreContent = false private var loadingMore = false init() { super.init(style: .plain) tableView.backgroundColor = .systemGroupedBackground tableView.separatorInset = .zero tableView.refreshControl = UIRefreshControl() tableView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) navigationItem.largeTitleDisplayMode = .never } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } deinit { loadingTask?.cancel() } func loadContent(refreshing: Bool) {} func loadMoreContent() {} func beginLoading(refreshing: Bool) { if !refreshing { beginNavigationLoading(spinner) } } func endLoading() { endNavigationLoading(spinner) refreshControl?.endRefreshing() } func resetPagination() { hasMoreContent = false loadingMore = false tableView.tableFooterView = nil } func finishPagination(hasMore: Bool) { hasMoreContent = hasMore loadingMore = false var configuration = moreButton.configuration configuration?.title = "Pull up or tap to load more" configuration?.showsActivityIndicator = false moreButton.configuration = configuration moreButton.accessibilityLabel = "Load more results" tableView.tableFooterView = hasMore ? paginationFooter() : nil } func failPagination() { loadingMore = false finishPagination(hasMore: hasMoreContent) } override func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.isDragging, hasMoreContent, !loadingMore else { return } let bottom = max( -scrollView.adjustedContentInset.top, scrollView.contentSize.height + scrollView.adjustedContentInset.bottom - scrollView.bounds.height ) if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() } } private func requestMoreContent() { guard hasMoreContent, !loadingMore else { return } loadingMore = true var configuration = moreButton.configuration configuration?.title = "Loading more…" configuration?.showsActivityIndicator = true moreButton.configuration = configuration moreButton.accessibilityLabel = "Loading more results" loadMoreContent() } private func paginationFooter() -> UIView { let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56)) moreButton.frame = footer.bounds moreButton.autoresizingMask = [.flexibleWidth, .flexibleHeight] footer.addSubview(moreButton) return footer } @objc private func refreshRequested() { loadContent(refreshing: true) } } final class EmptyBackgroundView: UIView { init(title: String, detail: String) { super.init(frame: .zero) let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .preferredFont(forTextStyle: .title2) titleLabel.textAlignment = .center let detailLabel = UILabel() detailLabel.text = detail detailLabel.font = .preferredFont(forTextStyle: .body) detailLabel.textColor = .secondaryLabel detailLabel.textAlignment = .center detailLabel.numberOfLines = 0 let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel]) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28), stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28), stack.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -40), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } } extension UIColor { convenience init?(hex: String) { let value = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) guard value.count == 6, let rgb = Int(value, radix: 16) else { return nil } self.init( red: CGFloat((rgb >> 16) & 0xff) / 255, green: CGFloat((rgb >> 8) & 0xff) / 255, blue: CGFloat(rgb & 0xff) / 255, alpha: 1 ) } } func markdown(_ source: String, textStyle: UIFont.TextStyle = .body) -> NSAttributedString { let attributed = (try? AttributedString( markdown: source, options: .init(interpretedSyntax: .full) )) ?? AttributedString(source) var mutable = attributed mutable.font = .preferredFont(forTextStyle: textStyle) mutable.foregroundColor = UIColor.label return NSAttributedString(mutable) } func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, image: UIImage? = nil) { var content = cell.defaultContentConfiguration() content.text = title content.secondaryText = detail content.secondaryTextProperties.numberOfLines = 2 content.image = image content.imageProperties.tintColor = .tintColor cell.contentConfiguration = content cell.backgroundColor = .systemBackground } func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) { let directory = row.kind == "dir" let detail = directory ? "Folder" : ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file) configureTextCell( cell, title: row.name, detail: detail, image: UIImage(systemName: directory ? "folder.fill" : "doc") ) cell.accessoryType = .disclosureIndicator } @MainActor func showRepositoryContent( _ row: RepositoryContentRow, context: AppContext, owner: String, repository: String, navigationController: UINavigationController? ) { let destination: UIViewController = row.kind == "dir" ? RepositoryDirectoryViewController( context: context, owner: owner, repository: repository, path: row.path, name: row.name ) : RepositoryFileViewController( context: context, owner: owner, repository: repository, path: row.path, name: row.name ) navigationController?.pushViewController(destination, animated: true) } func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) -> NSAttributedString { let output = NSMutableAttributedString() if let image = UIImage(systemName: symbol)?.withTintColor(color, renderingMode: .alwaysOriginal) { let attachment = NSTextAttachment(image: image) attachment.bounds = CGRect(x: 0, y: -2, width: font.pointSize, height: font.pointSize) output.append(NSAttributedString(attachment: attachment)) output.append(NSAttributedString(string: " ")) } output.append(NSAttributedString(string: text, attributes: [ .font: font, .foregroundColor: color, ])) return output }