import Highlighter import MarkdownUI import QuickLook import SwiftUI import UIKit @MainActor class MarkdownPageViewController: UIViewController, UIScrollViewDelegate { let context: AppContext let scrollView = UIScrollView() let stack = UIStackView() 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(context: AppContext) { self.context = context super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGroupedBackground scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.alwaysBounceVertical = true scrollView.delegate = self scrollView.refreshControl = UIRefreshControl() scrollView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) view.addSubview(scrollView) stack.axis = .vertical stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stack) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.topAnchor.constraint(equalTo: view.topAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 18), stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -18), stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 18), stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -18), stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -36), ]) } deinit { loadingTask?.cancel() } func loadContent(refreshing: Bool) {} func loadMoreContent() {} func beginLoading(refreshing: Bool) { if !refreshing { beginNavigationLoading(spinner) } } func endLoading() { endNavigationLoading(spinner) scrollView.refreshControl?.endRefreshing() } func replaceContent(_ views: [UIView]) { stack.arrangedSubviews.forEach { $0.removeFromSuperview() } views.forEach(stack.addArrangedSubview) } func resetPagination() { hasMoreContent = false loadingMore = false stack.removeArrangedSubview(moreButton) moreButton.removeFromSuperview() } func finishPagination(hasMore: Bool) { hasMoreContent = hasMore loadingMore = false guard hasMore else { return } var configuration = moreButton.configuration configuration?.title = "Pull up or tap to load more" configuration?.showsActivityIndicator = false moreButton.configuration = configuration moreButton.accessibilityLabel = "Load more results" if moreButton.superview !== stack { stack.addArrangedSubview(moreButton) } if moreButton.constraints.isEmpty { moreButton.heightAnchor.constraint(equalToConstant: 50).isActive = true } } func failPagination() { loadingMore = false finishPagination(hasMore: hasMoreContent) } 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 loadMoreContent() } @objc private func refreshRequested() { loadContent(refreshing: true) } } @MainActor final class IssueViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 private var page: IssuePage? private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, number: Int64) { self.owner = owner self.repository = repository self.number = number super.init(context: context) title = "Issue" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() let addComment = UIBarButtonItem( image: context.symbol("plus.bubble"), style: .plain, target: self, action: #selector(addComment) ) addComment.accessibilityLabel = "Add comment" let editIssue = UIBarButtonItem( title: "Edit", style: .plain, target: self, action: #selector(editIssue) ) navigationItem.rightBarButtonItems = [addComment, editIssue] loadContent(refreshing: false) } @objc private func addComment() { presentCommentEditor() } private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) } private func presentCommentEditor(_ comment: CommentRow? = nil) { let editor = CommentEditorViewController( context: context, owner: owner, repository: repository, number: number, comment: comment ) { [weak self] in guard let self else { return } self.dismiss(animated: true) { self.loadContent(refreshing: false) } } present(UINavigationController(rootViewController: editor), animated: true) } @objc private func editIssue() { let editor = IssueEditorViewController( context: context, owner: owner, repository: repository, number: number ) { [weak self] _ in guard let self else { return } self.dismiss(animated: true) { self.loadContent(refreshing: false) } } present(UINavigationController(rootViewController: editor), animated: true) } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { loadPage(currentPage + 1, 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.issue( owner: owner, repository: repository, number: number, page: requestedPage ) if requestedPage == 1 { page = result } else { page?.comments.append(contentsOf: result.comments) page?.hasMore = result.hasMore } currentPage = requestedPage if let page { replaceContent(detailViews( title: page.title, issueState: page.state, meta: page.meta, body: page.body, comments: page.comments, editComment: { [weak self] comment in self?.editComment(comment) }, milestone: page.milestone )) finishPagination(hasMore: page.hasMore) } } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { endLoading() } } } } @MainActor final class PullViewController: MarkdownPageViewController { private let owner: String private let repository: String private let number: Int64 private var page: PullPage? private var currentPage: UInt32 = 0 init(context: AppContext, owner: String, repository: String, number: Int64) { self.owner = owner self.repository = repository self.number = number super.init(context: context) title = "Pull Request" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { loadPage(1, refreshing: refreshing) } override func loadMoreContent() { loadPage(currentPage + 1, 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.pull( owner: owner, repository: repository, number: number, page: requestedPage ) if requestedPage == 1 { page = result } else { page?.files.append(contentsOf: result.files) page?.comments.append(contentsOf: result.comments) page?.hasMore = result.hasMore } currentPage = requestedPage if let page { var views = detailViews( title: page.title, meta: page.meta, body: page.body, comments: [] ) if !page.files.isEmpty { views.append(sectionLabel(page.filesRef)) views.append(contentsOf: page.files.map { file in detailButton(title: file.path, detail: file.status) { [weak self] in guard let self else { return } self.navigationController?.pushViewController( DiffViewController( context: self.context, source: .pull( owner: self.owner, repository: self.repository, number: self.number, path: file.path ) ), animated: true ) } }) } views.append(contentsOf: commentViews(page.comments)) replaceContent(views) finishPagination(hasMore: page.hasMore) } } catch { if !Task.isCancelled { show(error: error) failPagination() } } if requestedPage == 1 { endLoading() } } } } @MainActor final class FilesViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let sha: String private var rows: [FileRow] = [] init(context: AppContext, owner: String, repository: String, sha: String) { self.context = context self.owner = owner self.repository = repository self.sha = sha super.init() title = "Changed Files" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { rows = try await context.core.commitFiles( owner: owner, repository: repository, sha: sha ) tableView.reloadData() tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView(title: "No changed files", detail: "This commit does not contain file changes.") : 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: "file") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "file") let row = rows[indexPath.row] configureTextCell(cell, title: row.path, detail: row.status) cell.accessoryType = .disclosureIndicator return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let row = rows[indexPath.row] navigationController?.pushViewController( DiffViewController( context: context, source: .commit( owner: owner, repository: repository, sha: sha, path: row.path ) ), animated: true ) } } @MainActor final class RepositoryDirectoryViewController: RefreshingTableViewController { private let context: AppContext private let owner: String private let repository: String private let path: String private var rows: [RepositoryContentRow] = [] init(context: AppContext, owner: String, repository: String, path: String, name: String) { self.context = context self.owner = owner self.repository = repository self.path = path super.init() title = name } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() loadContent(refreshing: false) } override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() loadingTask = Task { do { rows = try await context.core.repositoryContents( owner: owner, repository: repository, path: path ) tableView.reloadData() tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView(title: "Empty folder", detail: "This folder does not contain any files.") : 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-content") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "repository-content") configureRepositoryContentCell(cell, row: rows[indexPath.row]) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) showRepositoryContent( rows[indexPath.row], context: context, owner: owner, repository: repository, navigationController: navigationController ) } } @MainActor final class RepositoryFileViewController: UIViewController, QLPreviewControllerDataSource { private enum MarkdownMode: Int { case preview, source } private let context: AppContext private let owner: String private let repository: String private let path: String private let spinner = UIActivityIndicatorView(style: .medium) private var loadingTask: Task? private var previewURL: URL? private var markdownSource: String? private var fileLanguage: String? private var displayedController: UIViewController? private var displayedView: UIView? private weak var sourceScrollView: UIScrollView? private weak var sourceTextView: UITextView? init(context: AppContext, owner: String, repository: String, path: String, name: String) { self.context = context self.owner = owner self.repository = repository self.path = path super.init(nibName: nil, bundle: nil) title = name } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground beginNavigationLoading(spinner) loadingTask = Task { do { let page = try await context.core.repositoryFile( owner: owner, repository: repository, path: path ) title = page.name fileLanguage = page.language.isEmpty ? nil : page.language switch page.kind { case "markdown": showMarkdown(page.text) case "source": showSource(page.text) default: try showQuickLook(page.data, name: page.name) } } catch { if !Task.isCancelled { show(error: error) } } endNavigationLoading(spinner) } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() updateSourceLayout() } deinit { loadingTask?.cancel() if let previewURL { try? FileManager.default.removeItem(at: previewURL) } } func numberOfPreviewItems(in controller: QLPreviewController) -> Int { previewURL == nil ? 0 : 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { previewURL! as NSURL } private func showMarkdown(_ source: String) { markdownSource = source navigationItem.prompt = title let modeControl = UISegmentedControl(items: ["Preview", "Source"]) modeControl.selectedSegmentIndex = MarkdownMode.preview.rawValue modeControl.addTarget(self, action: #selector(markdownModeChanged(_:)), for: .valueChanged) modeControl.accessibilityLabel = "Markdown view" navigationItem.titleView = modeControl showMarkdownPreview(source) } @objc private func markdownModeChanged(_ sender: UISegmentedControl) { guard let mode = MarkdownMode(rawValue: sender.selectedSegmentIndex), let source = markdownSource else { return } switch mode { case .preview: showMarkdownPreview(source) case .source: showSource(source) } } private func showMarkdownPreview(_ source: String) { removeDisplayedView() let preview = UIHostingController(rootView: RepositoryMarkdownPreview(source: source)) addChild(preview) preview.view.frame = view.bounds preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] displayedController = preview displayedView = preview.view view.addSubview(preview.view) preview.didMove(toParent: self) } private func showSource(_ source: String) { removeDisplayedView() let highlighter = Highlighter() highlighter?.setTheme( traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light" ) highlighter?.theme.setCodeFont(.monospacedSystemFont(ofSize: 13, weight: .regular)) let highlighted = highlighter?.highlight(source, as: fileLanguage) ?? NSAttributedString(string: source, attributes: [ .font: UIFont.monospacedSystemFont(ofSize: 13, weight: .regular), .foregroundColor: UIColor.label, ]) let text = NSMutableAttributedString(attributedString: highlighted) let paragraph = NSMutableParagraphStyle() paragraph.lineBreakMode = .byClipping text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length)) let scrollView = UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.alwaysBounceHorizontal = true scrollView.isDirectionalLockEnabled = true scrollView.showsHorizontalScrollIndicator = true scrollView.contentInsetAdjustmentBehavior = .never let textView = UITextView(frame: .zero) textView.textContainer.size = CGSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude ) textView.textContainer.lineBreakMode = .byClipping textView.textContainer.widthTracksTextView = false textView.textStorage.setAttributedString(text) textView.isEditable = false textView.isScrollEnabled = false textView.isSelectable = true sourceScrollView = scrollView sourceTextView = textView scrollView.addSubview(textView) displayedView = scrollView view.addSubview(scrollView) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) updateSourceLayout() } private func removeDisplayedView() { displayedController?.willMove(toParent: nil) displayedView?.removeFromSuperview() displayedController?.removeFromParent() displayedController = nil displayedView = nil sourceScrollView = nil sourceTextView = nil } private func updateSourceLayout() { guard let scrollView = sourceScrollView, let textView = sourceTextView else { return } textView.textContainer.widthTracksTextView = false textView.textContainer.size = CGSize( width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude ) textView.layoutManager.ensureLayout(for: textView.textContainer) let textSize = textView.layoutManager.usedRect(for: textView.textContainer).size let contentSize = CGSize( width: max( scrollView.bounds.width, ceil(textSize.width + textView.textContainerInset.left + textView.textContainerInset.right) ), height: max( scrollView.bounds.height, ceil(textSize.height + textView.textContainerInset.top + textView.textContainerInset.bottom) ) ) textView.frame = CGRect(origin: .zero, size: contentSize) scrollView.contentSize = contentSize } private func showQuickLook(_ data: Data, name: String) throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("\(UUID().uuidString)-\(name)") try data.write(to: url, options: .atomic) previewURL = url let preview = QLPreviewController() preview.dataSource = self addChild(preview) preview.view.frame = view.bounds preview.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(preview.view) preview.didMove(toParent: self) } } struct RepositoryMarkdownPreview: View { let source: String var body: some View { ScrollView { Markdown(source) .markdownTheme(.gitHub) .frame(maxWidth: .infinity, alignment: .leading) .padding() } .background(Color(uiColor: .systemBackground)) } } enum DiffSource { case commit(owner: String, repository: String, sha: String, path: String) case pull(owner: String, repository: String, number: Int64, path: String) } @MainActor final class DiffViewController: UIViewController { private let context: AppContext private let source: DiffSource private let textView = UITextView() private let spinner = UIActivityIndicatorView(style: .medium) private var loadingTask: Task? init(context: AppContext, source: DiffSource) { self.context = context self.source = source super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground textView.translatesAutoresizingMaskIntoConstraints = false textView.isEditable = false textView.isSelectable = true textView.alwaysBounceVertical = true textView.alwaysBounceHorizontal = true textView.showsHorizontalScrollIndicator = true textView.textContainer.widthTracksTextView = false textView.textContainer.lineFragmentPadding = 8 textView.refreshControl = UIRefreshControl() textView.refreshControl?.addTarget(self, action: #selector(reload), for: .valueChanged) view.addSubview(textView) NSLayoutConstraint.activate([ textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), textView.topAnchor.constraint(equalTo: view.topAnchor), textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) beginNavigationLoading(spinner) reload() } deinit { loadingTask?.cancel() } @objc private func reload() { loadingTask?.cancel() loadingTask = Task { do { let page: DiffPage switch source { case let .commit(owner, repository, sha, path): page = try await context.core.commitDiff( owner: owner, repository: repository, sha: sha, path: path ) case let .pull(owner, repository, number, path): page = try await context.core.pullDiff( owner: owner, repository: repository, number: number, path: path ) } title = page.title textView.attributedText = diffText(page) } catch { if !Task.isCancelled { show(error: error) } } endNavigationLoading(spinner) textView.refreshControl?.endRefreshing() } } private func diffText(_ page: DiffPage) -> NSAttributedString { let output = NSMutableAttributedString() let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) for line in page.lines { let text = String(format: "%4@ %4@ %@\n", line.oldNumber, line.newNumber, line.text) let color: UIColor switch line.kind { case "addition": color = UIColor.systemGreen.withAlphaComponent(0.16) case "removal": color = UIColor.systemRed.withAlphaComponent(0.16) case "hunk": color = UIColor.systemBlue.withAlphaComponent(0.14) case "header": color = UIColor.systemGray.withAlphaComponent(0.14) default: color = .clear } output.append(NSAttributedString(string: text, attributes: [ .font: font, .foregroundColor: UIColor.label, .backgroundColor: color, ])) } return output } } private func detailViews( title: String, issueState: String? = nil, meta: String, body: String, comments: [CommentRow], editComment: ((CommentRow) -> Void)? = nil, milestone: String = "" ) -> [UIView] { let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .preferredFont(forTextStyle: .title1) titleLabel.numberOfLines = 0 let metaLabel = UILabel() metaLabel.text = meta metaLabel.font = .preferredFont(forTextStyle: .subheadline) metaLabel.textColor = .secondaryLabel metaLabel.numberOfLines = 0 let bodyView = markdownView(body) var views: [UIView] = [issueState.map { issueTitle(titleLabel, state: $0) } ?? titleLabel, metaLabel] if !milestone.isEmpty { let milestoneLabel = UILabel() milestoneLabel.attributedText = symbolText( "flag.fill", text: milestone, font: .preferredFont(forTextStyle: .subheadline), color: .secondaryLabel ) milestoneLabel.accessibilityLabel = "Milestone \(milestone)" views.append(milestoneLabel) } return views + [separator(), bodyView] + commentViews(comments, editComment: editComment) } private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView { let symbol: String let color: UIColor let accessibilityLabel: String switch state { case "open": (symbol, color, accessibilityLabel) = ("exclamationmark.circle.fill", .systemGreen, "Open issue") case "closed": (symbol, color, accessibilityLabel) = ("checkmark.circle.fill", .systemPurple, "Closed issue") default: (symbol, color, accessibilityLabel) = ("questionmark.circle.fill", .systemGray, "Unknown issue state") } let icon = UIImageView(image: UIImage(systemName: symbol)) icon.tintColor = color icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .title1) icon.setContentHuggingPriority(.required, for: .horizontal) icon.isAccessibilityElement = true icon.accessibilityLabel = accessibilityLabel let stack = UIStackView(arrangedSubviews: [icon, titleLabel]) stack.axis = .horizontal stack.alignment = .firstBaseline stack.spacing = 8 return stack } private func commentViews( _ comments: [CommentRow], editComment: ((CommentRow) -> Void)? = nil ) -> [UIView] { guard !comments.isEmpty else { return [] } var views: [UIView] = [sectionLabel("Comments")] for comment in comments { let author = UILabel() author.text = comment.author author.font = .preferredFont(forTextStyle: .headline) var headerViews: [UIView] = [author, UIView()] if comment.canEdit, let editComment { var configuration = UIButton.Configuration.plain() configuration.title = "Edit" configuration.image = UIImage(systemName: "pencil") configuration.imagePadding = 5 configuration.contentInsets = .zero let button = UIButton( configuration: configuration, primaryAction: UIAction { _ in editComment(comment) } ) button.accessibilityLabel = "Edit comment by \(comment.author)" headerViews.append(button) } let header = UIStackView(arrangedSubviews: headerViews) header.alignment = .center let date = UILabel() date.text = comment.meta date.font = .preferredFont(forTextStyle: .caption1) date.textColor = .tertiaryLabel let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()]) stack.axis = .vertical stack.spacing = 6 views.append(stack) } return views } private func markdownView(_ source: String) -> UITextView { let view = UITextView() view.attributedText = markdown(source) view.isEditable = false view.isSelectable = true view.isScrollEnabled = false view.backgroundColor = .clear view.textContainerInset = .zero view.textContainer.lineFragmentPadding = 0 view.adjustsFontForContentSizeCategory = true return view } private func sectionLabel(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = .preferredFont(forTextStyle: .headline) return label } private func separator() -> UIView { let line = UIView() line.backgroundColor = .separator line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true return line } private func detailButton(title: String, detail: String, action: @escaping () -> Void) -> UIButton { var configuration = UIButton.Configuration.plain() configuration.title = title configuration.subtitle = detail configuration.image = UIImage(systemName: "chevron.right") configuration.imagePlacement = .trailing configuration.imagePadding = 8 configuration.contentInsets = .init(top: 10, leading: 0, bottom: 10, trailing: 0) let button = UIButton(configuration: configuration, primaryAction: UIAction { _ in action() }) button.contentHorizontalAlignment = .fill return button }