Files
Gotcha/ios/Sources/ServerScreens.swift
2026-08-03 20:34:39 +02:00

312 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
@MainActor
final class ServersViewController: UITableViewController {
private let context: AppContext
private var servers: [ServerRow] = []
private var mutationTask: Task<Void, Never>?
init(context: AppContext) {
self.context = context
super.init(style: .plain)
title = "Servers"
tableView.backgroundColor = .systemGroupedBackground
tableView.separatorInset = .zero
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
deinit {
mutationTask?.cancel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadServers()
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .add,
primaryAction: UIAction { [weak self] _ in self?.showServerEditor() }
)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
servers.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "server")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "server")
let server = servers[indexPath.row]
configureTextCell(cell, title: server.name, detail: server.url)
cell.accessoryType = .disclosureIndicator
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
do {
try context.selectServer(index: UInt32(indexPath.row))
} catch {
show(error: error)
}
}
override func tableView(
_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
) -> UISwipeActionsConfiguration? {
let server = servers[indexPath.row]
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
[weak self] _, _, completion in
self?.confirmDelete(server, index: indexPath.row, completion: completion)
?? completion(false)
}
deleteAction.image = context.symbol("trash")
let editAction = UIContextualAction(style: .normal, title: "Edit") {
[weak self] _, _, completion in
self?.showServerEditor(index: indexPath.row, completion: completion)
?? completion(false)
}
editAction.image = context.symbol("pencil")
let configuration = UISwipeActionsConfiguration(actions: [deleteAction, editAction])
configuration.performsFirstActionWithFullSwipe = true
return configuration
}
private func reloadServers() {
servers = context.core.servers()
tableView.reloadData()
tableView.backgroundView = servers.isEmpty
? EmptyBackgroundView(title: "No servers", detail: "Add a code hosting server to get started.")
: nil
}
private func showServerEditor(
index: Int? = nil,
completion swipeCompletion: ((Bool) -> Void)? = nil
) {
do {
let editor = try index.map { try context.core.serverEditor(index: UInt32($0)) }
let controller = ServerEditorViewController(
context: context,
index: index.map(UInt32.init),
editor: editor
) { [weak self] in
self?.reloadServers()
}
present(UINavigationController(rootViewController: controller), animated: true) {
swipeCompletion?(true)
}
} catch {
swipeCompletion?(false)
show(error: error)
}
}
private func confirmDelete(
_ server: ServerRow,
index: Int,
completion: @escaping (Bool) -> Void
) {
let alert = UIAlertController(
title: "Delete “\(server.name)”?",
message: "This removes the server configuration and access token from this device. It doesnt change anything on the server.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) })
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
guard let self else {
completion(false)
return
}
self.mutationTask?.cancel()
self.mutationTask = Task {
do {
try await self.context.core.deleteServer(index: UInt32(index))
guard !Task.isCancelled else {
completion(false)
return
}
completion(true)
self.reloadServers()
self.context.reloadAfterServerChange()
} catch {
completion(false)
if !Task.isCancelled { self.show(error: error) }
}
}
})
present(alert, animated: true)
}
}
@MainActor
final class ServerEditorViewController: UITableViewController, UITextFieldDelegate {
private let context: AppContext
private let index: UInt32?
private let editor: ServerEditor?
private let completion: () -> Void
private let nameField = UITextField()
private let urlField = UITextField()
private let tokenField = UITextField()
private let providerButton = UIButton(type: .system)
private var provider = ServerProvider.gitea
private var saveButton: UIBarButtonItem!
init(
context: AppContext,
index: UInt32?,
editor: ServerEditor?,
completion: @escaping () -> Void
) {
self.context = context
self.index = index
self.editor = editor
self.completion = completion
provider = editor?.provider ?? .gitea
super.init(style: .insetGrouped)
title = index == nil ? "Add Server" : "Edit Server"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
)
saveButton = UIBarButtonItem(
title: index == nil ? "Add" : "Save",
style: .done,
target: self,
action: #selector(save)
)
navigationItem.rightBarButtonItem = saveButton
configure(nameField, placeholder: "Work", contentType: .name)
configure(urlField, placeholder: "https://gitea.example.com", contentType: .URL)
urlField.keyboardType = .URL
urlField.autocapitalizationType = .none
configure(tokenField, placeholder: "Access token", contentType: nil)
tokenField.isSecureTextEntry = true
tokenField.autocapitalizationType = .none
tokenField.returnKeyType = .done
configureProviderButton()
nameField.text = editor?.name
urlField.text = editor?.url
if editor != nil {
tokenField.placeholder = "Leave unchanged"
}
}
override func numberOfSections(in tableView: UITableView) -> Int { 4 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
["API provider", "Name", "Server URL", "Access token"][section]
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
let control: UIView = indexPath.section == 0
? providerButton
: [nameField, urlField, tokenField][indexPath.section - 1]
control.translatesAutoresizingMaskIntoConstraints = false
cell.contentView.addSubview(control)
NSLayoutConstraint.activate([
control.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor, constant: 16),
control.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor, constant: -16),
control.topAnchor.constraint(equalTo: cell.contentView.topAnchor),
control.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor),
cell.contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48),
])
return cell
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField === nameField { urlField.becomeFirstResponder() }
else if textField === urlField { tokenField.becomeFirstResponder() }
else { save() }
return true
}
@objc private func save() {
view.endEditing(true)
saveButton.isEnabled = false
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
Task {
do {
if let index {
try await context.core.updateServer(
index: index,
name: nameField.text ?? "",
url: urlField.text ?? "",
token: tokenField.text ?? "",
provider: provider
)
context.reloadAfterServerChange()
} else {
let index = try await context.core.addServer(
name: nameField.text ?? "",
url: urlField.text ?? "",
token: tokenField.text ?? "",
provider: provider
)
try context.didAddServer(index: index)
}
completion()
dismiss(animated: true)
} catch {
navigationItem.rightBarButtonItem = saveButton
saveButton.isEnabled = true
show(error: error)
}
}
}
private func configure(
_ field: UITextField,
placeholder: String,
contentType: UITextContentType?
) {
field.placeholder = placeholder
field.textContentType = contentType
field.clearButtonMode = .whileEditing
field.delegate = self
field.returnKeyType = .next
field.adjustsFontForContentSizeCategory = true
field.font = .preferredFont(forTextStyle: .body)
}
private func configureProviderButton() {
providerButton.contentHorizontalAlignment = .leading
providerButton.showsMenuAsPrimaryAction = true
providerButton.changesSelectionAsPrimaryAction = true
providerButton.accessibilityLabel = "API provider"
providerButton.accessibilityValue = provider == .gitea ? "Gitea" : "Forgejo"
providerButton.menu = UIMenu(options: .singleSelection, children: [
UIAction(title: "Gitea", state: provider == .gitea ? .on : .off) { [weak self] _ in
self?.provider = .gitea
self?.urlField.placeholder = "https://gitea.example.com"
self?.providerButton.accessibilityValue = "Gitea"
},
UIAction(title: "Forgejo", state: provider == .forgejo ? .on : .off) { [weak self] _ in
self?.provider = .forgejo
self?.urlField.placeholder = "https://forgejo.example.com"
self?.providerButton.accessibilityValue = "Forgejo"
},
])
}
}