Files
Gotcha/ios/Sources/Support.swift
2026-07-31 15:02:21 +02:00

241 lines
8.3 KiB
Swift

import UIKit
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<Void, Never>?
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
}