Add first-class Actions management (#63)
This commit is contained in:
661
ios/Sources/ActionsScreens.swift
Normal file
661
ios/Sources/ActionsScreens.swift
Normal file
@@ -0,0 +1,661 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class ActionsViewController: RefreshingTableViewController {
|
||||
private enum Mode: Int { case workflows, runs }
|
||||
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let defaultBranch: String
|
||||
private let modeControl = UISegmentedControl(items: ["Workflows", "Runs"])
|
||||
private var mode = Mode.workflows
|
||||
private var workflows: [ActionWorkflowRow] = []
|
||||
private var runs: [ActionRunRow] = []
|
||||
private var currentPage: UInt32 = 0
|
||||
private var autoRefreshTask: Task<Void, Never>?
|
||||
private var loading = false
|
||||
private var requestGeneration = 0
|
||||
|
||||
init(context: AppContext, owner: String, repository: String, defaultBranch: String) {
|
||||
self.context = context
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.defaultBranch = defaultBranch
|
||||
super.init()
|
||||
title = "Actions"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
deinit { autoRefreshTask?.cancel() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
modeControl.selectedSegmentIndex = mode.rawValue
|
||||
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
|
||||
navigationItem.titleView = modeControl
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
autoRefreshTask?.cancel()
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
loadPage(1, refreshing: refreshing)
|
||||
}
|
||||
|
||||
override func loadMoreContent() {
|
||||
guard mode == .runs else { return }
|
||||
loadPage(currentPage + 1, refreshing: false)
|
||||
}
|
||||
|
||||
@objc private func modeChanged() {
|
||||
guard let mode = Mode(rawValue: modeControl.selectedSegmentIndex) else { return }
|
||||
self.mode = mode
|
||||
loadingTask?.cancel()
|
||||
loading = false
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
private func loadPage(_ page: UInt32, refreshing: Bool) {
|
||||
guard !loading else {
|
||||
refreshControl?.endRefreshing()
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
requestGeneration += 1
|
||||
let generation = requestGeneration
|
||||
let requestedMode = mode
|
||||
if page == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
defer {
|
||||
if requestGeneration == generation {
|
||||
loading = false
|
||||
if page == 1 { endLoading() }
|
||||
}
|
||||
}
|
||||
do {
|
||||
switch requestedMode {
|
||||
case .workflows:
|
||||
let result = try await context.core.actionWorkflows(
|
||||
owner: owner,
|
||||
repository: repository
|
||||
)
|
||||
guard !Task.isCancelled, mode == requestedMode else { return }
|
||||
workflows = result
|
||||
currentPage = 1
|
||||
finishPagination(hasMore: false)
|
||||
case .runs:
|
||||
let result = try await context.core.actionRuns(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
page: page
|
||||
)
|
||||
guard !Task.isCancelled, mode == requestedMode else { return }
|
||||
runs = page == 1 ? result.rows : runs + result.rows
|
||||
currentPage = page
|
||||
finishPagination(hasMore: result.hasMore)
|
||||
}
|
||||
tableView.reloadData()
|
||||
updateEmptyState()
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startAutoRefresh() {
|
||||
autoRefreshTask?.cancel()
|
||||
autoRefreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
guard let self, self.mode == .runs, !self.loading else { continue }
|
||||
self.loadPage(1, refreshing: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
let empty = mode == .workflows ? workflows.isEmpty : runs.isEmpty
|
||||
tableView.backgroundView = empty
|
||||
? EmptyBackgroundView(
|
||||
title: mode == .workflows ? "No workflows" : "No workflow runs",
|
||||
detail: mode == .workflows
|
||||
? "This repository has no Actions workflows."
|
||||
: "Dispatch a workflow to create the first run."
|
||||
)
|
||||
: nil
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
mode == .workflows ? workflows.count : runs.count
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "action")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "action")
|
||||
if mode == .workflows {
|
||||
let row = workflows[indexPath.row]
|
||||
cell.accessoryView = nil
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: row.name,
|
||||
detail: "\(row.state) · \(row.path)",
|
||||
image: context.symbol("play.square")
|
||||
)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
} else {
|
||||
cell.accessoryType = .none
|
||||
let row = runs[indexPath.row]
|
||||
configureActionCell(cell, row: row, context: context)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if mode == .workflows {
|
||||
let workflow = workflows[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
WorkflowDispatchViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
workflow: workflow,
|
||||
defaultBranch: defaultBranch
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
} else {
|
||||
navigationController?.pushViewController(
|
||||
ActionRunViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
run: runs[indexPath.row]
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkflowDispatchViewController: UITableViewController, UITextViewDelegate {
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let workflow: ActionWorkflowRow
|
||||
private let referenceField = UITextField()
|
||||
private let inputsView = UITextView()
|
||||
private lazy var runButton = UIBarButtonItem(
|
||||
title: "Run Workflow",
|
||||
primaryAction: UIAction { [weak self] _ in self?.dispatch() }
|
||||
)
|
||||
private var dispatchTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
context: AppContext,
|
||||
owner: String,
|
||||
repository: String,
|
||||
workflow: ActionWorkflowRow,
|
||||
defaultBranch: String
|
||||
) {
|
||||
self.context = context
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.workflow = workflow
|
||||
referenceField.text = defaultBranch
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Dispatch"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
deinit { dispatchTask?.cancel() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
referenceField.placeholder = "Branch or tag"
|
||||
referenceField.autocapitalizationType = .none
|
||||
referenceField.autocorrectionType = .no
|
||||
referenceField.clearButtonMode = .whileEditing
|
||||
referenceField.addTarget(self, action: #selector(referenceChanged), for: .editingChanged)
|
||||
inputsView.font = .preferredFont(forTextStyle: .body)
|
||||
inputsView.adjustsFontForContentSizeCategory = true
|
||||
inputsView.autocapitalizationType = .none
|
||||
inputsView.autocorrectionType = .no
|
||||
inputsView.accessibilityLabel = "Workflow dispatch inputs"
|
||||
navigationItem.rightBarButtonItem = runButton
|
||||
referenceChanged()
|
||||
}
|
||||
|
||||
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? {
|
||||
[workflow.name, "Git Reference", "Inputs"][section]
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
section == 2 ? "Enter one workflow_dispatch input per line as KEY=VALUE. Leave blank when the workflow has no inputs." : nil
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
if indexPath.section == 0 {
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = workflow.path
|
||||
content.secondaryText = workflow.state
|
||||
cell.contentConfiguration = content
|
||||
cell.selectionStyle = .none
|
||||
} else {
|
||||
let view = indexPath.section == 1 ? referenceField : inputsView
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.contentView.addSubview(view)
|
||||
NSLayoutConstraint.activate([
|
||||
view.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
|
||||
view.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
|
||||
view.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8),
|
||||
view.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8),
|
||||
])
|
||||
cell.selectionStyle = .none
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
indexPath.section == 2 ? 140 : UITableView.automaticDimension
|
||||
}
|
||||
|
||||
private func dispatch() {
|
||||
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||
dispatchTask?.cancel()
|
||||
dispatchTask = Task {
|
||||
do {
|
||||
try await context.core.dispatchActionWorkflow(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
workflow: workflow.id,
|
||||
reference: referenceField.text ?? "",
|
||||
inputs: [inputsView.text]
|
||||
)
|
||||
navigationController?.popViewController(animated: true)
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
navigationItem.rightBarButtonItem?.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func referenceChanged() {
|
||||
runButton.isEnabled = !(referenceField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ActionRunViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let runID: Int64
|
||||
private var page: ActionRunPage?
|
||||
private var autoRefreshTask: Task<Void, Never>?
|
||||
private var loading = false
|
||||
|
||||
init(context: AppContext, owner: String, repository: String, run: ActionRunRow) {
|
||||
self.context = context
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
runID = run.id
|
||||
super.init()
|
||||
title = "Run #\(run.number)"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
deinit { autoRefreshTask?.cancel() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
autoRefreshTask?.cancel()
|
||||
autoRefreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
guard let self, !self.loading else { continue }
|
||||
self.loadContent(refreshing: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
autoRefreshTask?.cancel()
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
guard !loading else {
|
||||
refreshControl?.endRefreshing()
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
defer {
|
||||
loading = false
|
||||
endLoading()
|
||||
}
|
||||
do {
|
||||
page = try await context.core.actionRun(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
run: runID
|
||||
)
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = page?.jobs.isEmpty == true
|
||||
? EmptyBackgroundView(
|
||||
title: "No jobs yet",
|
||||
detail: "Pull to refresh while the run is queued."
|
||||
)
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 1 : page?.jobs.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "Run" : "Jobs"
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "actionDetail")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "actionDetail")
|
||||
if indexPath.section == 0 {
|
||||
let run = page!.run
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: run.title,
|
||||
detail: "\(actionStateLabel(run.state)) · \(run.event) · \(run.branch)\n\(run.meta)",
|
||||
image: context.symbol(actionStateSymbolName(run.state))
|
||||
)
|
||||
tintActionIcon(in: cell, state: run.state)
|
||||
cell.selectionStyle = .none
|
||||
cell.accessoryType = .none
|
||||
} else {
|
||||
let job = page!.jobs[indexPath.row]
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: job.name,
|
||||
detail: job.meta,
|
||||
image: context.symbol(actionStateSymbolName(job.state))
|
||||
)
|
||||
tintActionIcon(in: cell, state: job.state)
|
||||
cell.selectionStyle = .default
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 1 else { return }
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let job = page!.jobs[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
ActionJobViewController(
|
||||
context: context,
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
runID: runID,
|
||||
job: job
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ActionJobViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private let runID: Int64
|
||||
private let job: ActionJobRow
|
||||
private var page: ActionJobLogPage?
|
||||
|
||||
init(
|
||||
context: AppContext,
|
||||
owner: String,
|
||||
repository: String,
|
||||
runID: Int64,
|
||||
job: ActionJobRow
|
||||
) {
|
||||
self.context = context
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.runID = runID
|
||||
self.job = job
|
||||
super.init()
|
||||
title = job.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 {
|
||||
defer { endLoading() }
|
||||
do {
|
||||
page = try await context.core.actionJobLog(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
run: runID,
|
||||
job: job.id
|
||||
)
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = page?.groups.isEmpty == true
|
||||
? EmptyBackgroundView(
|
||||
title: "No log groups",
|
||||
detail: "This job has no log output yet. Pull to refresh."
|
||||
)
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { page == nil ? 0 : 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 1 : page?.groups.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "Job" : "Tasks"
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "jobDetail")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "jobDetail")
|
||||
if indexPath.section == 0 {
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: page!.job.name,
|
||||
detail: page!.job.meta,
|
||||
image: context.symbol(actionStateSymbolName(page!.job.state))
|
||||
)
|
||||
tintActionIcon(in: cell, state: page!.job.state)
|
||||
cell.selectionStyle = .none
|
||||
cell.accessoryType = .none
|
||||
} else {
|
||||
let group = page!.groups[indexPath.row]
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: group.name,
|
||||
detail: group.duration.isEmpty
|
||||
? actionStateLabel(group.state)
|
||||
: "\(actionStateLabel(group.state)) · \(group.duration)",
|
||||
image: context.symbol(actionStateSymbolName(group.state))
|
||||
)
|
||||
tintActionIcon(in: cell, state: group.state)
|
||||
cell.selectionStyle = .default
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 1 else { return }
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let group = page!.groups[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
ActionLogTextViewController(title: group.name, text: group.text),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ActionLogTextViewController: UIViewController {
|
||||
private let logTitle: String
|
||||
private let text: String
|
||||
|
||||
init(title: String, text: String) {
|
||||
logTitle = title
|
||||
self.text = text
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.title = title
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(
|
||||
for: .monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||
)
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.text = text
|
||||
textView.accessibilityLabel = "\(logTitle) log"
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(textView)
|
||||
NSLayoutConstraint.activate([
|
||||
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func configureActionCell(_ cell: UITableViewCell, row: ActionRunRow, context: AppContext) {
|
||||
configureTextCell(
|
||||
cell,
|
||||
title: "#\(row.number) \(row.title)",
|
||||
detail: "\(actionStateLabel(row.state)) · \(row.event) · \(row.branch)\n\(row.meta)",
|
||||
image: context.symbol(actionStateSymbolName(row.state))
|
||||
)
|
||||
tintActionIcon(in: cell, state: row.state)
|
||||
cell.accessoryView = nil
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
|
||||
private func tintActionIcon(in cell: UITableViewCell, state: ActionState) {
|
||||
guard var content = cell.contentConfiguration as? UIListContentConfiguration else { return }
|
||||
content.imageProperties.tintColor = actionStateColor(state)
|
||||
cell.contentConfiguration = content
|
||||
}
|
||||
|
||||
private func actionStateColor(_ state: ActionState) -> UIColor {
|
||||
switch state {
|
||||
case .succeeded: return .systemGreen
|
||||
case .failed: return .systemRed
|
||||
case .cancelled: return .systemGray
|
||||
case .skipped: return .tertiaryLabel
|
||||
case .queued: return .systemOrange
|
||||
case .waiting: return .systemOrange
|
||||
case .inProgress: return .systemBlue
|
||||
case .unknown: return .secondaryLabel
|
||||
}
|
||||
}
|
||||
|
||||
private func actionStateLabel(_ state: ActionState) -> String {
|
||||
switch state {
|
||||
case .queued: return "Queued"
|
||||
case .waiting: return "Waiting"
|
||||
case .inProgress: return "In Progress"
|
||||
case .succeeded: return "Succeeded"
|
||||
case .failed: return "Failed"
|
||||
case .cancelled: return "Cancelled"
|
||||
case .skipped: return "Skipped"
|
||||
case .unknown: return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func actionStateSymbolName(_ state: ActionState) -> String {
|
||||
switch state {
|
||||
case .succeeded: return "checkmark.circle.fill"
|
||||
case .failed: return "xmark.circle.fill"
|
||||
case .cancelled: return "slash.circle.fill"
|
||||
case .skipped: return "minus.circle.fill"
|
||||
case .queued: return "clock.fill"
|
||||
case .waiting: return "hourglass.circle.fill"
|
||||
case .inProgress: return "circle.fill"
|
||||
case .unknown: return "questionmark.circle.fill"
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,20 @@ final class AppContext {
|
||||
}
|
||||
|
||||
func makeRootController() -> UIViewController {
|
||||
let roots: [UIViewController] = [
|
||||
HomeViewController(context: self),
|
||||
repositoryRoot(mode: .issues),
|
||||
repositoryRoot(mode: .commits),
|
||||
PullsViewController(context: self),
|
||||
repositoryRoot(mode: .milestones),
|
||||
]
|
||||
let items = [
|
||||
("Home", "house", "house.fill"),
|
||||
("Issues", "exclamationmark.circle", "exclamationmark.circle.fill"),
|
||||
("Repos", "books.vertical", "books.vertical.fill"),
|
||||
("PRs", "arrow.triangle.pull", "arrow.triangle.pull"),
|
||||
("Milestones", "flag", "flag.fill"),
|
||||
]
|
||||
navigationControllers = zip(roots, items).map { root, item in
|
||||
rebuildTabs(preservingHomeStack: false)
|
||||
return tabs
|
||||
}
|
||||
|
||||
func applyPrimaryDestinations() {
|
||||
rebuildTabs(preservingHomeStack: true)
|
||||
}
|
||||
|
||||
private func rebuildTabs(preservingHomeStack: Bool) {
|
||||
let destinations = core.settings().primaryDestinations
|
||||
let home = preservingHomeStack ? navigationControllers.first : nil
|
||||
let roots = destinations.map(destinationController)
|
||||
let items = [("Home", "house", "house.fill")] + destinations.map(destinationItem)
|
||||
let destinationNavigations = zip(roots, items.dropFirst()).map { root, item in
|
||||
let navigation = UINavigationController(rootViewController: root)
|
||||
navigation.tabBarItem = UITabBarItem(
|
||||
title: item.0,
|
||||
@@ -42,8 +41,17 @@ final class AppContext {
|
||||
)
|
||||
return navigation
|
||||
}
|
||||
let homeNavigation = home ?? UINavigationController(
|
||||
rootViewController: HomeViewController(context: self)
|
||||
)
|
||||
homeNavigation.tabBarItem = UITabBarItem(
|
||||
title: items[0].0,
|
||||
image: UIImage(systemName: items[0].1),
|
||||
selectedImage: UIImage(systemName: items[0].2)
|
||||
)
|
||||
navigationControllers = [homeNavigation] + destinationNavigations
|
||||
tabs.viewControllers = navigationControllers
|
||||
return tabs
|
||||
(homeNavigation.viewControllers.first as? HomeViewController)?.refreshPrimaryDestinations()
|
||||
}
|
||||
|
||||
func showStartupErrorIfNeeded() {
|
||||
@@ -57,33 +65,50 @@ final class AppContext {
|
||||
}
|
||||
|
||||
func reloadAfterServerChange() {
|
||||
let replacements: [(Int, UIViewController)] = [
|
||||
(0, HomeViewController(context: self)),
|
||||
(1, repositoryRoot(mode: .issues)),
|
||||
(2, repositoryRoot(mode: .commits)),
|
||||
(3, PullsViewController(context: self)),
|
||||
(4, repositoryRoot(mode: .milestones)),
|
||||
]
|
||||
for (index, root) in replacements {
|
||||
navigationControllers[index].setViewControllers([root], animated: false)
|
||||
}
|
||||
rebuildTabs(preservingHomeStack: false)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func route(widgetURL: URL) -> Bool {
|
||||
guard widgetURL.scheme == "gotcha" else { return false }
|
||||
let index: Int
|
||||
switch widgetURL.host {
|
||||
case "home": index = 0
|
||||
case "pulls": index = 3
|
||||
case "home":
|
||||
tabs.selectedIndex = 0
|
||||
navigationControllers[0].popToRootViewController(animated: false)
|
||||
case "pulls":
|
||||
if let index = primaryDestinations.firstIndex(of: .pullRequests).map({ $0 + 1 }) {
|
||||
tabs.selectedIndex = index
|
||||
navigationControllers[index].popToRootViewController(animated: false)
|
||||
} else {
|
||||
tabs.selectedIndex = 0
|
||||
navigationControllers[0].pushViewController(
|
||||
PullsViewController(context: self),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
default: return false
|
||||
}
|
||||
tabs.selectedIndex = index
|
||||
navigationControllers[index].popToRootViewController(animated: false)
|
||||
return true
|
||||
}
|
||||
|
||||
var primaryDestinations: [PrimaryDestination] {
|
||||
core.settings().primaryDestinations
|
||||
}
|
||||
|
||||
var secondaryDestinations: [PrimaryDestination] {
|
||||
PrimaryDestination.allCases.filter { !primaryDestinations.contains($0) }
|
||||
}
|
||||
|
||||
func show(_ destination: PrimaryDestination, from navigation: UINavigationController?) {
|
||||
if let index = primaryDestinations.firstIndex(of: destination).map({ $0 + 1 }) {
|
||||
tabs.selectedIndex = index
|
||||
navigationControllers[index].popToRootViewController(animated: false)
|
||||
} else {
|
||||
navigation?.pushViewController(destinationController(destination), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func didAddServer(index: UInt32) throws {
|
||||
try selectServer(index: index)
|
||||
}
|
||||
@@ -154,7 +179,7 @@ final class AppContext {
|
||||
do {
|
||||
try selectServer(index: UInt32(index))
|
||||
} catch {
|
||||
tabs.present(errorAlert(error.localizedDescription), animated: true)
|
||||
tabs.present(errorAlert(errorMessage(error)), animated: true)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -216,4 +241,54 @@ final class AppContext {
|
||||
}
|
||||
return RepositoriesViewController(context: self, mode: mode)
|
||||
}
|
||||
|
||||
private func destinationController(_ destination: PrimaryDestination) -> UIViewController {
|
||||
switch destination {
|
||||
case .issues: return repositoryRoot(mode: .issues)
|
||||
case .repositories: return repositoryRoot(mode: .commits)
|
||||
case .pullRequests: return PullsViewController(context: self)
|
||||
case .milestones: return repositoryRoot(mode: .milestones)
|
||||
case .actions: return repositoryRoot(mode: .actions)
|
||||
case .serverActivity: return ServerActivityViewController(context: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func destinationItem(_ destination: PrimaryDestination) -> (String, String, String) {
|
||||
switch destination {
|
||||
case .issues: return ("Issues", "exclamationmark.circle", "exclamationmark.circle.fill")
|
||||
case .repositories: return ("Repos", "books.vertical", "books.vertical.fill")
|
||||
case .pullRequests: return ("PRs", "arrow.triangle.pull", "arrow.triangle.pull")
|
||||
case .milestones: return ("Milestones", "flag", "flag.fill")
|
||||
case .actions: return ("Actions", "play.square.stack", "play.square.stack.fill")
|
||||
case .serverActivity: return ("Activity", "person.3", "person.3.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PrimaryDestination: CaseIterable {
|
||||
public static var allCases: [PrimaryDestination] {
|
||||
[.issues, .repositories, .pullRequests, .milestones, .actions, .serverActivity]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .issues: return "Issues"
|
||||
case .repositories: return "Repositories"
|
||||
case .pullRequests: return "Pull Requests"
|
||||
case .milestones: return "Milestones"
|
||||
case .actions: return "Actions"
|
||||
case .serverActivity: return "Server Activity"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .issues: return "exclamationmark.circle"
|
||||
case .repositories: return "books.vertical"
|
||||
case .pullRequests: return "arrow.triangle.pull"
|
||||
case .milestones: return "flag"
|
||||
case .actions: return "play.square.stack"
|
||||
case .serverActivity: return "person.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,37 @@ import UIKit
|
||||
|
||||
@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 enum Timeline: Int {
|
||||
case user
|
||||
case allUsers
|
||||
case notifications
|
||||
}
|
||||
|
||||
private let context: AppContext
|
||||
private var page: HomePage?
|
||||
private var filter = ActivityFilter.all
|
||||
private var nextPage: UInt32?
|
||||
private var activities: [ActivityRow] { page?.activities ?? [] }
|
||||
private var timeline = Timeline.user
|
||||
private var userNextPage: UInt32?
|
||||
private var serverActivities: [ActivityRow] = []
|
||||
private var serverNextPage: UInt32?
|
||||
private let notificationStatusControl = UISegmentedControl(items: ["Open", "Closed"])
|
||||
private lazy var notificationHeaderView: UIView = {
|
||||
let header = UIView()
|
||||
notificationStatusControl.translatesAutoresizingMaskIntoConstraints = false
|
||||
header.addSubview(notificationStatusControl)
|
||||
NSLayoutConstraint.activate([
|
||||
notificationStatusControl.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
||||
notificationStatusControl.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
||||
])
|
||||
return header
|
||||
}()
|
||||
private var notificationStatus = NotificationStatus.open
|
||||
private var notificationRows: [NotificationRow] = []
|
||||
private var notificationPage: UInt32 = 0
|
||||
private var notificationsHaveMore = false
|
||||
|
||||
private var activities: [ActivityRow] {
|
||||
timeline == .allUsers ? serverActivities : page?.activities ?? []
|
||||
}
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
@@ -33,6 +45,14 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification")
|
||||
notificationStatusControl.selectedSegmentIndex = 0
|
||||
notificationStatusControl.accessibilityLabel = "Notification status"
|
||||
notificationStatusControl.addTarget(
|
||||
self,
|
||||
action: #selector(notificationStatusChanged),
|
||||
for: .valueChanged
|
||||
)
|
||||
let servers = UIBarButtonItem(
|
||||
image: context.symbol("server.rack"),
|
||||
primaryAction: UIAction { [weak self] _ in
|
||||
@@ -61,8 +81,11 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
guard page == nil else { return }
|
||||
loadContent(refreshing: false)
|
||||
if page == nil {
|
||||
loadContent(refreshing: false)
|
||||
} else if timeline == .notifications {
|
||||
loadNotifications(page: 1, refreshing: false)
|
||||
}
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
@@ -74,15 +97,31 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
refreshControl?.endRefreshing()
|
||||
return
|
||||
}
|
||||
loadPage(1, refreshing: refreshing)
|
||||
switch timeline {
|
||||
case .user:
|
||||
loadUserPage(1, refreshing: refreshing)
|
||||
case .allUsers:
|
||||
loadServerActivityPage(1, refreshing: refreshing)
|
||||
case .notifications:
|
||||
loadNotifications(page: 1, refreshing: refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
override func loadMoreContent() {
|
||||
guard let nextPage else { return }
|
||||
loadPage(nextPage, refreshing: false)
|
||||
switch timeline {
|
||||
case .user:
|
||||
guard let userNextPage else { return }
|
||||
loadUserPage(userNextPage, refreshing: false)
|
||||
case .allUsers:
|
||||
guard let serverNextPage else { return }
|
||||
loadServerActivityPage(serverNextPage, refreshing: false)
|
||||
case .notifications:
|
||||
guard notificationsHaveMore else { return }
|
||||
loadNotifications(page: notificationPage + 1, refreshing: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||
private func loadUserPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||
if requestedPage == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
@@ -92,62 +131,106 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
do {
|
||||
let result = try await context.core.home(
|
||||
page: requestedPage,
|
||||
filter: filter.coreValue
|
||||
filter: .all
|
||||
)
|
||||
guard !Task.isCancelled, timeline == .user else { return }
|
||||
if requestedPage == 1 {
|
||||
page = result
|
||||
} else {
|
||||
page?.activities.append(contentsOf: result.activities)
|
||||
page?.nextPage = result.nextPage
|
||||
}
|
||||
nextPage = result.nextPage
|
||||
userNextPage = 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,
|
||||
onFilter: { [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)
|
||||
},
|
||||
onNotifications: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.navigationController?.pushViewController(
|
||||
NotificationsViewController(context: self.context),
|
||||
animated: true
|
||||
)
|
||||
},
|
||||
onServerActivity: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.navigationController?.pushViewController(
|
||||
ServerActivityViewController(context: self.context),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
)
|
||||
} }
|
||||
updateActivities()
|
||||
setPanelTitle(self, "Home", server: page?.serverName)
|
||||
if requestedPage == 1 { refreshPrimaryDestinations() }
|
||||
updateRows()
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
if !Task.isCancelled, timeline == .user {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1 { endLoading() }
|
||||
if requestedPage == 1, timeline == .user { endLoading() }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadServerActivityPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||
if requestedPage == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let result = try await context.core.serverActivity(page: requestedPage)
|
||||
guard !Task.isCancelled, timeline == .allUsers else { return }
|
||||
if requestedPage == 1 {
|
||||
serverActivities = result.rows
|
||||
} else {
|
||||
serverActivities.append(contentsOf: result.rows)
|
||||
}
|
||||
serverNextPage = result.hasMore ? requestedPage + 1 : nil
|
||||
finishPagination(hasMore: result.hasMore)
|
||||
updateRows()
|
||||
} catch {
|
||||
if !Task.isCancelled, timeline == .allUsers {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1, timeline == .allUsers { endLoading() }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadNotifications(page requestedPage: UInt32, refreshing: Bool) {
|
||||
if requestedPage == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let result = try await context.core.notifications(
|
||||
status: notificationStatus,
|
||||
page: requestedPage
|
||||
)
|
||||
guard !Task.isCancelled, timeline == .notifications else { return }
|
||||
if requestedPage == 1 {
|
||||
notificationRows = result.rows
|
||||
} else {
|
||||
notificationRows.append(contentsOf: result.rows)
|
||||
}
|
||||
notificationPage = requestedPage
|
||||
notificationsHaveMore = result.hasMore
|
||||
finishPagination(hasMore: result.hasMore)
|
||||
updateRows()
|
||||
} catch {
|
||||
if !Task.isCancelled, timeline == .notifications {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1, timeline == .notifications { endLoading() }
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
activities.count
|
||||
timeline == .notifications ? notificationRows.count : activities.count
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
if timeline == .notifications {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: "notification",
|
||||
for: indexPath
|
||||
) as! NotificationCell
|
||||
cell.configure(notificationRows[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
|
||||
let row = activities[indexPath.row]
|
||||
@@ -162,22 +245,98 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
92
|
||||
timeline == .notifications ? UITableView.automaticDimension : 92
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
viewForHeaderInSection section: Int
|
||||
) -> UIView? {
|
||||
guard timeline == .notifications else { return nil }
|
||||
return notificationHeaderView
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
heightForHeaderInSection section: Int
|
||||
) -> CGFloat {
|
||||
timeline == .notifications ? 44 : .leastNormalMagnitude
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
context.route(activities[indexPath.row])
|
||||
if timeline == .notifications {
|
||||
openNotification(notificationRows[indexPath.row])
|
||||
} else {
|
||||
context.route(activities[indexPath.row])
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActivities() {
|
||||
private func updateRows() {
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = activities.isEmpty
|
||||
? EmptyBackgroundView(
|
||||
title: "No matching activity",
|
||||
detail: "This server has no recent activity of the selected type."
|
||||
let isEmpty = timeline == .notifications ? notificationRows.isEmpty : activities.isEmpty
|
||||
guard isEmpty else {
|
||||
tableView.backgroundView = nil
|
||||
return
|
||||
}
|
||||
let message: (String, String) = switch timeline {
|
||||
case .user:
|
||||
("No activity", "This user has no recent activity.")
|
||||
case .allUsers:
|
||||
("No server activity", "No activity is visible to this server account.")
|
||||
case .notifications:
|
||||
notificationStatus == .open
|
||||
? ("No open notifications", "This server has no open notifications.")
|
||||
: ("No closed notifications", "This server has no closed notifications.")
|
||||
}
|
||||
tableView.backgroundView = EmptyBackgroundView(title: message.0, detail: message.1)
|
||||
}
|
||||
|
||||
private func selectTimeline(_ rawValue: Int) {
|
||||
guard let selected = Timeline(rawValue: rawValue), selected != timeline else { return }
|
||||
timeline = selected
|
||||
setPanelTitle(self, "Home", server: page?.serverName)
|
||||
refreshPrimaryDestinations()
|
||||
tableView.reloadData()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
@objc private func notificationStatusChanged() {
|
||||
notificationStatus = notificationStatusControl.selectedSegmentIndex == 0 ? .open : .closed
|
||||
loadNotifications(page: 1, refreshing: false)
|
||||
}
|
||||
|
||||
private func openNotification(_ row: NotificationRow) {
|
||||
guard row.target != .none else { return }
|
||||
guard row.unread else {
|
||||
context.route(row)
|
||||
return
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
try await context.core.markNotificationRead(serverId: row.serverId, id: row.id)
|
||||
guard !Task.isCancelled else { return }
|
||||
context.route(row)
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPrimaryDestinations() {
|
||||
tableView.tableHeaderView = page.map { page in
|
||||
HeatmapView(
|
||||
page: page,
|
||||
selectedTimeline: timeline.rawValue,
|
||||
destinations: context.secondaryDestinations.filter { $0 != .serverActivity },
|
||||
onTimeline: { [weak self] timeline in self?.selectTimeline(timeline) },
|
||||
onDestination: { [weak self] destination in
|
||||
guard let self else { return }
|
||||
self.context.show(destination, from: self.navigationController)
|
||||
}
|
||||
)
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -197,13 +356,14 @@ func activitySymbolName(for icon: ActivityIcon) -> String {
|
||||
final class HeatmapView: UIView {
|
||||
private let cells: [HeatCell]
|
||||
private let calendar = Calendar(identifier: .gregorian)
|
||||
private let controlsScroller = UIScrollView()
|
||||
|
||||
init(
|
||||
page: HomePage,
|
||||
selectedFilter: Int,
|
||||
onFilter: @escaping (Int) -> Void,
|
||||
onNotifications: @escaping () -> Void,
|
||||
onServerActivity: @escaping () -> Void
|
||||
selectedTimeline: Int,
|
||||
destinations: [PrimaryDestination],
|
||||
onTimeline: @escaping (Int) -> Void,
|
||||
onDestination: @escaping (PrimaryDestination) -> Void
|
||||
) {
|
||||
cells = page.heatCells
|
||||
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
|
||||
@@ -218,16 +378,14 @@ final class HeatmapView: UIView {
|
||||
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"),
|
||||
let timelinesView = UIStackView()
|
||||
timelinesView.axis = .horizontal
|
||||
let timelineItems = [
|
||||
("person.crop.circle", "person.crop.circle.fill", "Your activity"),
|
||||
("person.3", "person.3.fill", "All users activity"),
|
||||
("bell", "bell.fill", "Notifications"),
|
||||
]
|
||||
for (index, item) in filterItems.enumerated() {
|
||||
for (index, item) in timelineItems.enumerated() {
|
||||
let button = UIButton(type: .custom, primaryAction: UIAction { action in
|
||||
guard
|
||||
let button = action.sender as? UIButton,
|
||||
@@ -238,47 +396,77 @@ final class HeatmapView: UIView {
|
||||
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
|
||||
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
|
||||
}
|
||||
onFilter(button.tag)
|
||||
onTimeline(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.isSelected = index == selectedTimeline
|
||||
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)
|
||||
timelinesView.addArrangedSubview(button)
|
||||
}
|
||||
let serverActivity = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction { _ in onServerActivity() }
|
||||
)
|
||||
serverActivity.setImage(UIImage(systemName: "person.3"), for: .normal)
|
||||
serverActivity.tintColor = .secondaryLabel
|
||||
serverActivity.accessibilityLabel = "Server Activity"
|
||||
serverActivity.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
filters.addArrangedSubview(serverActivity)
|
||||
let notifications = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction { _ in onNotifications() }
|
||||
)
|
||||
notifications.setImage(UIImage(systemName: "bell"), for: .normal)
|
||||
notifications.tintColor = .secondaryLabel
|
||||
notifications.accessibilityLabel = "Notifications"
|
||||
notifications.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
filters.addArrangedSubview(notifications)
|
||||
addSubview(filters)
|
||||
|
||||
let destinationsView = UIStackView()
|
||||
destinationsView.axis = .horizontal
|
||||
for destination in destinations {
|
||||
let button = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction { _ in onDestination(destination) }
|
||||
)
|
||||
let symbol = destination == .milestones ? "flag.fill" : destination.symbolName
|
||||
button.setImage(UIImage(systemName: symbol), for: .normal)
|
||||
button.tintColor = .secondaryLabel
|
||||
button.accessibilityLabel = destination.title
|
||||
button.accessibilityHint = "Opens from Home"
|
||||
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
destinationsView.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
let controls = UIStackView(arrangedSubviews: [timelinesView])
|
||||
controls.axis = .horizontal
|
||||
controls.alignment = .center
|
||||
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||
if !destinations.isEmpty {
|
||||
controls.addArrangedSubview(destinationsView)
|
||||
}
|
||||
controlsScroller.showsHorizontalScrollIndicator = false
|
||||
controlsScroller.translatesAutoresizingMaskIntoConstraints = false
|
||||
controlsScroller.addSubview(controls)
|
||||
addSubview(controlsScroller)
|
||||
NSLayoutConstraint.activate([
|
||||
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
filters.topAnchor.constraint(equalTo: topAnchor, constant: 132),
|
||||
filters.heightAnchor.constraint(equalToConstant: 44),
|
||||
controlsScroller.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
controlsScroller.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
controlsScroller.topAnchor.constraint(equalTo: topAnchor, constant: 132),
|
||||
controlsScroller.heightAnchor.constraint(equalToConstant: 44),
|
||||
controls.leadingAnchor.constraint(
|
||||
equalTo: controlsScroller.contentLayoutGuide.leadingAnchor,
|
||||
constant: 8
|
||||
),
|
||||
controls.trailingAnchor.constraint(
|
||||
equalTo: controlsScroller.contentLayoutGuide.trailingAnchor,
|
||||
constant: -8
|
||||
),
|
||||
controls.topAnchor.constraint(equalTo: controlsScroller.contentLayoutGuide.topAnchor),
|
||||
controls.bottomAnchor.constraint(
|
||||
equalTo: controlsScroller.contentLayoutGuide.bottomAnchor
|
||||
),
|
||||
controls.heightAnchor.constraint(equalTo: controlsScroller.frameLayoutGuide.heightAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let inset = max(0, (controlsScroller.bounds.width - controlsScroller.contentSize.width) / 2)
|
||||
controlsScroller.contentInset.left = inset
|
||||
controlsScroller.contentInset.right = inset
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard
|
||||
let first = cells.first,
|
||||
|
||||
128
ios/Sources/NavigationSettingsViewController.swift
Normal file
128
ios/Sources/NavigationSettingsViewController.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class NavigationSettingsViewController: UITableViewController {
|
||||
private let context: AppContext
|
||||
private var selected: [PrimaryDestination]
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
selected = context.primaryDestinations
|
||||
super.init(style: .insetGrouped)
|
||||
title = "Primary Navigation"
|
||||
navigationItem.rightBarButtonItem = editButtonItem
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
private var available: [PrimaryDestination] {
|
||||
PrimaryDestination.allCases.filter { !selected.contains($0) }
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? selected.count : available.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "Shown After Home" : "Available on Home"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
section == 0
|
||||
? "Choose up to four destinations. Tap Edit to reorder them. Home is always first."
|
||||
: "Destinations not in the tab bar remain available as buttons on Home."
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let destination = indexPath.section == 0 ? selected[indexPath.row] : available[indexPath.row]
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = destination.title
|
||||
content.image = UIImage(systemName: destination.symbolName)
|
||||
content.imageProperties.tintColor = .tintColor
|
||||
if indexPath.section == 1 && selected.count == 4 {
|
||||
content.textProperties.color = .secondaryLabel
|
||||
content.imageProperties.tintColor = .tertiaryLabel
|
||||
cell.selectionStyle = .none
|
||||
} else if indexPath.section == 1 {
|
||||
let add = UIImageView(image: UIImage(systemName: "plus.circle.fill"))
|
||||
add.tintColor = .tintColor
|
||||
cell.accessoryView = add
|
||||
cell.accessibilityHint = "Adds this destination to the tab bar"
|
||||
}
|
||||
cell.contentConfiguration = content
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
|
||||
indexPath.section == 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
moveRowAt sourceIndexPath: IndexPath,
|
||||
to destinationIndexPath: IndexPath
|
||||
) {
|
||||
guard sourceIndexPath.section == 0, destinationIndexPath.section == 0 else {
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
let destination = selected.remove(at: sourceIndexPath.row)
|
||||
selected.insert(destination, at: destinationIndexPath.row)
|
||||
save()
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||||
toProposedIndexPath proposedDestinationIndexPath: IndexPath
|
||||
) -> IndexPath {
|
||||
guard proposedDestinationIndexPath.section == 0 else {
|
||||
return IndexPath(row: max(selected.count - 1, 0), section: 0)
|
||||
}
|
||||
return proposedDestinationIndexPath
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
editingStyleForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell.EditingStyle {
|
||||
indexPath.section == 0 ? .delete : .none
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
commit editingStyle: UITableViewCell.EditingStyle,
|
||||
forRowAt indexPath: IndexPath
|
||||
) {
|
||||
guard editingStyle == .delete, indexPath.section == 0 else { return }
|
||||
selected.remove(at: indexPath.row)
|
||||
save()
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 1, selected.count < 4 else { return }
|
||||
selected.append(available[indexPath.row])
|
||||
save()
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
do {
|
||||
try context.core.setPrimaryDestinations(destinations: selected)
|
||||
context.applyPrimaryDestinations()
|
||||
} catch {
|
||||
selected = context.primaryDestinations
|
||||
tableView.reloadData()
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ final class NotificationsViewController: RefreshingTableViewController {
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init()
|
||||
title = "Notifications"
|
||||
setPanelTitle(self, "Notifications", server: context.core.activeServerName())
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
||||
@@ -62,7 +62,7 @@ final class PullsViewController: RefreshingTableViewController {
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init()
|
||||
title = "Pull Requests"
|
||||
setPanelTitle(self, "Pull Requests", server: context.core.activeServerName())
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -75,20 +75,28 @@ final class PullsViewController: RefreshingTableViewController {
|
||||
tableView.register(PullCell.self, forCellReuseIdentifier: "pull")
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 116
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
updateFilterMenu()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if navigationController?.viewControllers.first === self {
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
guard context.core.activeServerIndex() != nil else {
|
||||
tableView.backgroundView = EmptyBackgroundView(
|
||||
|
||||
@@ -7,11 +7,20 @@ final class RepositoriesViewController: RefreshingTableViewController {
|
||||
private var rows: [RepositoryRow] = []
|
||||
private var currentPage: UInt32 = 0
|
||||
|
||||
private var panelTitle: String {
|
||||
switch mode {
|
||||
case .issues: return "Issues"
|
||||
case .commits: return "Repositories"
|
||||
case .milestones: return "Milestones"
|
||||
case .actions: return "Actions"
|
||||
}
|
||||
}
|
||||
|
||||
init(context: AppContext, mode: RepositoryPane) {
|
||||
self.context = context
|
||||
self.mode = mode
|
||||
super.init()
|
||||
title = context.core.activeServerName() ?? "Repositories"
|
||||
setPanelTitle(self, panelTitle, server: context.core.activeServerName())
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -19,19 +28,27 @@ final class RepositoriesViewController: RefreshingTableViewController {
|
||||
|
||||
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 viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if navigationController?.viewControllers.first === self {
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
loadPage(1, refreshing: refreshing)
|
||||
}
|
||||
@@ -118,6 +135,13 @@ final class RepositoriesViewController: RefreshingTableViewController {
|
||||
owner: row.owner,
|
||||
repository: row.name
|
||||
)
|
||||
case .actions:
|
||||
destination = ActionsViewController(
|
||||
context: context,
|
||||
owner: row.owner,
|
||||
repository: row.name,
|
||||
defaultBranch: row.defaultBranch
|
||||
)
|
||||
}
|
||||
navigationController?.pushViewController(destination, animated: true)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ final class ServerActivityViewController: RefreshingTableViewController {
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init()
|
||||
title = "Server Activity"
|
||||
setPanelTitle(self, "Server Activity", server: context.core.activeServerName())
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
||||
@@ -29,25 +29,29 @@ final class SettingsViewController: UITableViewController {
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
notificationSwitch.isOn = context.core.settings().notificationsEnabled
|
||||
tableView.reloadSections(IndexSet(integer: 1), with: .none)
|
||||
Task {
|
||||
notificationStatus = await context.notifications.authorizationDescription()
|
||||
updateNotificationSettingsCell()
|
||||
}
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 3 }
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 1 : 2
|
||||
section == 2 ? 2 : 1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "Appearance" : "Notifications"
|
||||
["Appearance", "Navigation", "Notifications"][section]
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
if section == 0 {
|
||||
return "Follow iOS automatically or choose a fixed appearance."
|
||||
}
|
||||
if section == 1 {
|
||||
return "Home is always present. Choose and order up to four additional destinations."
|
||||
}
|
||||
return "Gotcha checks periodically for server notifications. iOS decides when background refresh runs; delivery style, sounds, Focus, and summaries remain under your control in iOS Settings."
|
||||
}
|
||||
|
||||
@@ -55,6 +59,15 @@ final class SettingsViewController: UITableViewController {
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
if indexPath.section == 1 {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = "Primary Destinations"
|
||||
content.secondaryText = "\(context.primaryDestinations.count) selected"
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
guard indexPath.section == 0 else {
|
||||
if indexPath.row == 0 {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
@@ -87,8 +100,14 @@ final class SettingsViewController: UITableViewController {
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 1, indexPath.row == 1 else { return }
|
||||
context.notifications.openSystemSettings()
|
||||
if indexPath.section == 1 {
|
||||
navigationController?.pushViewController(
|
||||
NavigationSettingsViewController(context: context),
|
||||
animated: true
|
||||
)
|
||||
} else if indexPath.section == 2, indexPath.row == 1 {
|
||||
context.notifications.openSystemSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func appearanceChanged() {
|
||||
@@ -119,7 +138,7 @@ final class SettingsViewController: UITableViewController {
|
||||
}
|
||||
|
||||
private func updateNotificationSettingsCell() {
|
||||
guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 1)) else { return }
|
||||
guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 2)) else { return }
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = "Notification Settings"
|
||||
content.secondaryText = notificationStatus
|
||||
|
||||
@@ -46,9 +46,14 @@ func errorAlert(_ message: String) -> UIAlertController {
|
||||
return alert
|
||||
}
|
||||
|
||||
func errorMessage(_ error: Error) -> String {
|
||||
if case let GotchaError.Message(message) = error { return message }
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
func show(error: Error) {
|
||||
present(errorAlert(error.localizedDescription), animated: true)
|
||||
present(errorAlert(errorMessage(error)), animated: true)
|
||||
}
|
||||
|
||||
func beginNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
||||
@@ -392,3 +397,31 @@ func separator() -> UIView {
|
||||
line.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||
return line
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setPanelTitle(_ viewController: UIViewController, _ title: String, server: String?) {
|
||||
viewController.title = title
|
||||
viewController.navigationItem.titleView = nil
|
||||
if #available(iOS 26.0, *) {
|
||||
viewController.navigationItem.subtitle = server.flatMap { $0.isEmpty ? nil : $0 }
|
||||
return
|
||||
}
|
||||
guard let server, !server.isEmpty else { return }
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
titleLabel.adjustsFontForContentSizeCategory = true
|
||||
titleLabel.textAlignment = .center
|
||||
let serverLabel = UILabel()
|
||||
serverLabel.text = server
|
||||
serverLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
serverLabel.adjustsFontForContentSizeCategory = true
|
||||
serverLabel.textColor = .secondaryLabel
|
||||
serverLabel.textAlignment = .center
|
||||
let labels = UIStackView(arrangedSubviews: [titleLabel, serverLabel])
|
||||
labels.axis = .vertical
|
||||
labels.spacing = 0
|
||||
labels.alignment = .center
|
||||
labels.accessibilityLabel = "\(title), \(server)"
|
||||
viewController.navigationItem.titleView = labels
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user