Show remote activity on iPhone Home
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
extension Notification.Name {
|
||||
static let ironStorageLocalStoreDidChange = Notification.Name(
|
||||
"de.rfc1437.ironstorage.local-store-did-change"
|
||||
)
|
||||
}
|
||||
|
||||
@main
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
@@ -85,6 +91,26 @@ private final class ShellViewController: UITableViewController {
|
||||
private var page: MobilePage
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var loadGeneration = 0
|
||||
private var homePage: MobileHomePage?
|
||||
private var homeTask: Task<Void, Never>?
|
||||
private var homeProgressTask: Task<Void, Never>?
|
||||
private var homeOperation: MobileHomeOperation?
|
||||
private var homeGeneration = 0
|
||||
private var isHomeWorking = false
|
||||
|
||||
private enum HomeSection {
|
||||
case summary
|
||||
case incoming
|
||||
case outgoing
|
||||
case empty
|
||||
case notice
|
||||
}
|
||||
|
||||
private enum HomeRequest: Equatable {
|
||||
case refreshIfStale
|
||||
case refresh
|
||||
case pull
|
||||
}
|
||||
|
||||
init(page: MobilePage) {
|
||||
shellTab = page.tab
|
||||
@@ -94,6 +120,12 @@ private final class ShellViewController: UITableViewController {
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -103,6 +135,10 @@ private final class ShellViewController: UITableViewController {
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
homeOperation?.cancel()
|
||||
homeTask?.cancel()
|
||||
homeProgressTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
@@ -113,25 +149,73 @@ private final class ShellViewController: UITableViewController {
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
reloadShell()
|
||||
if shellTab == .home, page.state == .ready, homePage != nil {
|
||||
runHome(.refreshIfStale)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
loadTask?.cancel()
|
||||
cancelHomeWork()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
page.state == .ready ? 1 : 0
|
||||
guard page.state == .ready else { return 0 }
|
||||
if shellTab == .home {
|
||||
return homeSections.count
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
1
|
||||
guard shellTab == .home else { return 1 }
|
||||
switch homeSections[section] {
|
||||
case .summary: return homePage?.summaries.count ?? 0
|
||||
case .incoming: return homePage?.incoming.count ?? 0
|
||||
case .outgoing: return homePage?.outgoing.count ?? 0
|
||||
case .empty, .notice: return 1
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
guard shellTab == .home else { return nil }
|
||||
switch homeSections[section] {
|
||||
case .summary: return "Status"
|
||||
case .incoming: return "Incoming Activity"
|
||||
case .outgoing: return "Outgoing Activity"
|
||||
case .empty: return "Remote Activity"
|
||||
case .notice: return "Result"
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForFooterInSection section: Int
|
||||
) -> String? {
|
||||
guard shellTab == .home, let homePage else { return nil }
|
||||
switch homeSections[section] {
|
||||
case .summary:
|
||||
return freshnessDescription(homePage)
|
||||
case .incoming where homePage.incomingTotal > UInt32(homePage.incoming.count):
|
||||
return "Showing \(homePage.incoming.count) of \(homePage.incomingTotal) incoming commits."
|
||||
case .outgoing where homePage.outgoingTotal > UInt32(homePage.outgoing.count):
|
||||
return "Showing \(homePage.outgoing.count) of \(homePage.outgoingTotal) outgoing commits."
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
if shellTab == .home {
|
||||
return homeCell(at: indexPath)
|
||||
}
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.image = UIImage(systemName: page.systemImage)
|
||||
@@ -144,7 +228,42 @@ private final class ShellViewController: UITableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard shellTab == .home, let homePage else { return }
|
||||
let section = homeSections[indexPath.section]
|
||||
let commit: MobileHomeCommit
|
||||
let incoming: Bool
|
||||
switch section {
|
||||
case .incoming:
|
||||
commit = homePage.incoming[indexPath.row]
|
||||
incoming = true
|
||||
case .outgoing:
|
||||
commit = homePage.outgoing[indexPath.row]
|
||||
incoming = false
|
||||
default:
|
||||
return
|
||||
}
|
||||
navigationController?.pushViewController(
|
||||
MobileCommitActivityViewController(commit: commit, incoming: incoming),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
if shellTab == .home, page.state == .ready {
|
||||
guard !isHomeWorking else {
|
||||
refreshControl?.endRefreshing()
|
||||
return
|
||||
}
|
||||
runHome(.pull)
|
||||
} else {
|
||||
reloadShell()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func localStoreDidChange() {
|
||||
guard shellTab != .home else { return }
|
||||
reloadShell()
|
||||
}
|
||||
|
||||
@@ -174,6 +293,15 @@ private final class ShellViewController: UITableViewController {
|
||||
target: self,
|
||||
action: #selector(setupRequested)
|
||||
)
|
||||
} else if page.state == .ready, shellTab == .home {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "arrow.clockwise"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(statusRefreshRequested)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Refresh remote status"
|
||||
navigationItem.rightBarButtonItem?.isEnabled = !isHomeWorking
|
||||
} else if page.state == .ready, shellTab == .preferences {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "Update Token",
|
||||
@@ -189,6 +317,9 @@ private final class ShellViewController: UITableViewController {
|
||||
|
||||
guard page.state != .ready else {
|
||||
contentUnavailableConfiguration = nil
|
||||
if shellTab == .home {
|
||||
loadHomeIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
var configuration = page.state == .loading
|
||||
@@ -208,6 +339,263 @@ private final class ShellViewController: UITableViewController {
|
||||
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
|
||||
}
|
||||
|
||||
@objc private func statusRefreshRequested() {
|
||||
runHome(.refresh)
|
||||
}
|
||||
|
||||
private var homeSections: [HomeSection] {
|
||||
guard let homePage else { return [] }
|
||||
var sections: [HomeSection] = [.summary]
|
||||
if !homePage.incoming.isEmpty {
|
||||
sections.append(.incoming)
|
||||
}
|
||||
if !homePage.outgoing.isEmpty {
|
||||
sections.append(.outgoing)
|
||||
}
|
||||
if homePage.incomingTotal == 0, homePage.outgoingTotal == 0 {
|
||||
sections.append(.empty)
|
||||
}
|
||||
if homePage.notice != nil {
|
||||
sections.append(.notice)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
private func homeCell(at indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
guard let homePage else { return cell }
|
||||
var content = cell.defaultContentConfiguration()
|
||||
switch homeSections[indexPath.section] {
|
||||
case .summary:
|
||||
let row = homePage.summaries[indexPath.row]
|
||||
content.image = UIImage(systemName: row.systemImage)
|
||||
content.text = row.title
|
||||
content.secondaryText = row.detail
|
||||
cell.selectionStyle = .none
|
||||
case .incoming:
|
||||
configureCommit(
|
||||
homePage.incoming[indexPath.row],
|
||||
content: &content,
|
||||
cell: cell
|
||||
)
|
||||
case .outgoing:
|
||||
configureCommit(
|
||||
homePage.outgoing[indexPath.row],
|
||||
content: &content,
|
||||
cell: cell
|
||||
)
|
||||
case .empty:
|
||||
content.image = UIImage(systemName: "checkmark.circle")
|
||||
content.text = "No Remote Activity"
|
||||
content.secondaryText = "There are no commits to pull or push."
|
||||
cell.selectionStyle = .none
|
||||
case .notice:
|
||||
if let notice = homePage.notice {
|
||||
content.image = UIImage(systemName: notice.systemImage)
|
||||
content.text = notice.title
|
||||
content.secondaryText = notice.detail
|
||||
}
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.selectionStyle = .none
|
||||
}
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.contentConfiguration = content
|
||||
return cell
|
||||
}
|
||||
|
||||
private func configureCommit(
|
||||
_ commit: MobileHomeCommit,
|
||||
content: inout UIListContentConfiguration,
|
||||
cell: UITableViewCell
|
||||
) {
|
||||
content.image = UIImage(systemName: commit.systemImage)
|
||||
content.text = commit.title
|
||||
content.secondaryText = "\(commit.detail)\n\(formattedDate(commit.timestamp))"
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessibilityHint = "Shows password-store changes in this commit."
|
||||
}
|
||||
|
||||
private func freshnessDescription(_ page: MobileHomePage) -> String {
|
||||
let refreshed = page.refreshedAt.map(formattedDate)
|
||||
switch page.freshness {
|
||||
case .neverRefreshed:
|
||||
return "Not refreshed. Pull down to update the local clone or tap Refresh Status."
|
||||
case .cached:
|
||||
return "Cached · Last refreshed \(refreshed ?? "at an unknown time"). This is not live status."
|
||||
case .current:
|
||||
return "Current · Refreshed \(refreshed ?? "just now")."
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ timestamp: Int64) -> String {
|
||||
Date(timeIntervalSince1970: TimeInterval(timestamp)).formatted(
|
||||
date: .abbreviated,
|
||||
time: .shortened
|
||||
)
|
||||
}
|
||||
|
||||
private func loadHomeIfNeeded() {
|
||||
guard homePage == nil, homeTask == nil, !isHomeWorking else { return }
|
||||
homeGeneration += 1
|
||||
let current = homeGeneration
|
||||
let operation = mobileHomeOperation()
|
||||
homeOperation = operation
|
||||
isHomeWorking = true
|
||||
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||
showHomeLoading()
|
||||
homeTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileHomePage, HomeFailure>.success(try operation.cached())
|
||||
} catch let error as MobileHomeFfiError {
|
||||
return .failure(HomeFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self, current == homeGeneration else { return }
|
||||
finishHome(result, request: nil)
|
||||
if case .success = result {
|
||||
runHome(.refreshIfStale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runHome(_ request: HomeRequest) {
|
||||
guard page.state == .ready, shellTab == .home, !isHomeWorking else { return }
|
||||
cancelHomeWork()
|
||||
homeGeneration += 1
|
||||
let current = homeGeneration
|
||||
let operation = mobileHomeOperation()
|
||||
homeOperation = operation
|
||||
isHomeWorking = true
|
||||
navigationItem.rightBarButtonItem?.isEnabled = false
|
||||
showHomeProgress(operation.progress())
|
||||
homeProgressTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled, let self, current == homeGeneration else { return }
|
||||
showHomeProgress(operation.progress())
|
||||
}
|
||||
}
|
||||
homeTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
let page: MobileHomePage = switch request {
|
||||
case .refreshIfStale: try operation.refreshIfStale()
|
||||
case .refresh: try operation.refresh()
|
||||
case .pull: try operation.pull()
|
||||
}
|
||||
return Result<MobileHomePage, HomeFailure>.success(page)
|
||||
} catch let error as MobileHomeFfiError {
|
||||
return .failure(HomeFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard let self, current == homeGeneration else { return }
|
||||
finishHome(result, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishHome(
|
||||
_ result: Result<MobileHomePage, HomeFailure>,
|
||||
request: HomeRequest?
|
||||
) {
|
||||
homeProgressTask?.cancel()
|
||||
homeProgressTask = nil
|
||||
homeTask = nil
|
||||
homeOperation = nil
|
||||
isHomeWorking = false
|
||||
navigationItem.titleView = nil
|
||||
navigationItem.prompt = nil
|
||||
navigationItem.rightBarButtonItem?.isEnabled = true
|
||||
refreshControl?.endRefreshing()
|
||||
switch result {
|
||||
case let .success(homePage):
|
||||
self.homePage = homePage
|
||||
contentUnavailableConfiguration = nil
|
||||
tableView.reloadData()
|
||||
if request == .pull {
|
||||
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
|
||||
if let notice = homePage.notice {
|
||||
UIAccessibility.post(notification: .announcement, argument: notice.title)
|
||||
}
|
||||
}
|
||||
case let .failure(failure):
|
||||
tableView.reloadData()
|
||||
if homePage == nil {
|
||||
showHomeFailure(failure)
|
||||
} else if failure.kind != .interrupted {
|
||||
presentHomeFailure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showHomeLoading() {
|
||||
var configuration = UIContentUnavailableConfiguration.loading()
|
||||
configuration.text = "Loading Activity"
|
||||
configuration.secondaryText = "Reading cached remote-branch state from storage."
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
private func showHomeFailure(_ failure: HomeFailure) {
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "exclamationmark.triangle")
|
||||
configuration.text = failure.title
|
||||
configuration.secondaryText = failure.detail
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
private func presentHomeFailure(_ failure: HomeFailure) {
|
||||
let alert = UIAlertController(
|
||||
title: failure.title,
|
||||
message: failure.detail,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
if failure.kind == .authentication || failure.kind == .secureStorage {
|
||||
alert.addAction(UIAlertAction(title: "Preferences", style: .default) { [weak self] _ in
|
||||
self?.tabBarController?.selectedIndex = 3
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func showHomeProgress(_ progress: MobileHomeProgress) {
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.startAnimating()
|
||||
spinner.accessibilityLabel = "In progress"
|
||||
let label = UILabel()
|
||||
label.text = progress.title
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
||||
stack.spacing = 8
|
||||
navigationItem.titleView = stack
|
||||
navigationItem.prompt = progress.detail
|
||||
}
|
||||
|
||||
private func cancelHomeWork() {
|
||||
homeGeneration += 1
|
||||
homeOperation?.cancel()
|
||||
homeOperation = nil
|
||||
homeTask?.cancel()
|
||||
homeTask = nil
|
||||
homeProgressTask?.cancel()
|
||||
homeProgressTask = nil
|
||||
isHomeWorking = false
|
||||
navigationItem.titleView = nil
|
||||
navigationItem.prompt = nil
|
||||
refreshControl?.endRefreshing()
|
||||
navigationItem.rightBarButtonItem?.isEnabled = true
|
||||
}
|
||||
|
||||
private func stateImage(_ state: MobileShellState) -> String {
|
||||
switch state {
|
||||
case .loading: "hourglass"
|
||||
@@ -219,6 +607,96 @@ private final class ShellViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeFailure: Error, Sendable {
|
||||
let kind: MobileHomeErrorKind
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
init(_ error: MobileHomeFfiError) {
|
||||
switch error {
|
||||
case let .Failed(kind, title, detail):
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
static let unexpected = HomeFailure(
|
||||
kind: .repository,
|
||||
title: "Remote Activity Failed",
|
||||
detail: "IronStorage could not load the storage-provided Home page."
|
||||
)
|
||||
|
||||
private init(kind: MobileHomeErrorKind, title: String, detail: String) {
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MobileCommitActivityViewController: UITableViewController {
|
||||
private let commit: MobileHomeCommit
|
||||
|
||||
init(commit: MobileHomeCommit, incoming: Bool) {
|
||||
self.commit = commit
|
||||
super.init(style: .insetGrouped)
|
||||
title = incoming ? "Incoming Commit" : "Outgoing Commit"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 1 : max(commit.changes.count, 1)
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
section == 0 ? "Commit" : "Password Store Changes"
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
if indexPath.section == 0 {
|
||||
content.image = UIImage(systemName: commit.systemImage)
|
||||
content.text = commit.title
|
||||
content.secondaryText = "\(commit.detail)\n\(formattedDate)"
|
||||
} else if commit.changes.isEmpty {
|
||||
content.image = UIImage(systemName: "minus.circle")
|
||||
content.text = "No File Changes"
|
||||
content.secondaryText = "This commit does not change password-store files."
|
||||
} else {
|
||||
let change = commit.changes[indexPath.row]
|
||||
content.image = UIImage(systemName: change.systemImage)
|
||||
content.text = change.title
|
||||
content.secondaryText = change.detail
|
||||
}
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.contentConfiguration = content
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
|
||||
private var formattedDate: String {
|
||||
Date(timeIntervalSince1970: TimeInterval(commit.timestamp)).formatted(
|
||||
date: .abbreviated,
|
||||
time: .shortened
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TokenUpdateViewController: UITableViewController {
|
||||
private let accountField = UITextField()
|
||||
|
||||
Reference in New Issue
Block a user