Navigate password folders on iPhone
This commit is contained in:
@@ -25,6 +25,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private protocol MobileTabRoot: AnyObject {
|
||||
var shellTab: MobileTab { get }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
private let tabs = UITabBarController()
|
||||
@@ -34,7 +39,9 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
func makeRootController() -> UIViewController {
|
||||
let shell = mobileShellFixture(state: .loading)
|
||||
navigationControllers = shell.pages.map { page in
|
||||
let root = ShellViewController(page: page)
|
||||
let root: UIViewController = page.tab == .passwords
|
||||
? PasswordDirectoryViewController(shellPage: page)
|
||||
: ShellViewController(page: page)
|
||||
let navigation = UINavigationController(rootViewController: root)
|
||||
navigation.navigationBar.prefersLargeTitles = true
|
||||
navigation.tabBarItem = UITabBarItem(
|
||||
@@ -56,7 +63,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
) {
|
||||
guard
|
||||
let navigation = viewController as? UINavigationController,
|
||||
let root = navigation.viewControllers.first as? ShellViewController
|
||||
let root = navigation.viewControllers.first as? MobileTabRoot
|
||||
else {
|
||||
return
|
||||
}
|
||||
@@ -86,7 +93,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ShellViewController: UITableViewController {
|
||||
private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
fileprivate let shellTab: MobileTab
|
||||
private var page: MobilePage
|
||||
private var loadTask: Task<Void, Never>?
|
||||
@@ -607,6 +614,289 @@ private final class ShellViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
||||
fileprivate let shellTab = MobileTab.passwords
|
||||
private let path: String?
|
||||
private var shellPage: MobilePage?
|
||||
private var directoryPage: MobilePasswordPage?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var loadGeneration = 0
|
||||
|
||||
init(shellPage: MobilePage, path: String? = nil) {
|
||||
self.shellPage = shellPage
|
||||
self.path = path
|
||||
super.init(style: .insetGrouped)
|
||||
title = shellPage.title
|
||||
navigationItem.largeTitleDisplayMode = path == nil ? .always : .never
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private init(path: String, title: String) {
|
||||
self.path = path
|
||||
shellPage = nil
|
||||
super.init(style: .insetGrouped)
|
||||
self.title = title
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(localStoreDidChange),
|
||||
name: .ironStorageLocalStoreDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
if let shellPage {
|
||||
apply(shellPage)
|
||||
} else {
|
||||
showLoading()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
reload()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
loadGeneration += 1
|
||||
loadTask?.cancel()
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
directoryPage?.rows.isEmpty == false ? 1 : 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
directoryPage?.rows.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
guard let row = directoryPage?.rows[indexPath.row] else { return cell }
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.image = UIImage(systemName: row.systemImage)
|
||||
content.text = row.title
|
||||
content.secondaryText = row.detail
|
||||
content.textProperties.numberOfLines = 2
|
||||
content.secondaryTextProperties.numberOfLines = 1
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessibilityLabel = "\(row.title), \(row.detail)"
|
||||
cell.accessibilityHint = row.kind == .directory
|
||||
? "Opens this password folder."
|
||||
: "Opens the locked password viewer."
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let row = directoryPage?.rows[indexPath.row] else { return }
|
||||
let controller: UIViewController = switch row.kind {
|
||||
case .directory:
|
||||
PasswordDirectoryViewController(path: row.path, title: row.title)
|
||||
case .entry:
|
||||
LockedPasswordViewController(entry: row)
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
reload()
|
||||
}
|
||||
|
||||
@objc private func localStoreDidChange() {
|
||||
guard viewIfLoaded?.window != nil else { return }
|
||||
reload()
|
||||
}
|
||||
|
||||
private func reload() {
|
||||
loadGeneration += 1
|
||||
let generation = loadGeneration
|
||||
let selectedID = tableView.indexPathForSelectedRow.flatMap {
|
||||
directoryPage?.rows[$0.row].id
|
||||
}
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if path == nil {
|
||||
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
||||
guard
|
||||
!Task.isCancelled,
|
||||
generation == loadGeneration,
|
||||
let page = shell.pages.first(where: { $0.tab == .passwords })
|
||||
else { return }
|
||||
apply(page)
|
||||
guard page.state == .ready else { return }
|
||||
} else {
|
||||
showLoading()
|
||||
}
|
||||
let path = self.path
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobilePasswordPage, PasswordFailure>.success(
|
||||
try mobilePasswordPage(path: path)
|
||||
)
|
||||
} catch let error as MobilePasswordFfiError {
|
||||
return .failure(PasswordFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, generation == loadGeneration else { return }
|
||||
finish(result, selectedID: selectedID)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(_ page: MobilePage) {
|
||||
shellPage = page
|
||||
title = page.title
|
||||
guard page.state == .ready else {
|
||||
directoryPage = nil
|
||||
tableView.reloadData()
|
||||
refreshControl?.endRefreshing()
|
||||
var configuration = page.state == .loading
|
||||
? UIContentUnavailableConfiguration.loading()
|
||||
: UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = page.state == .loading
|
||||
? nil
|
||||
: UIImage(systemName: page.state == .error ? "exclamationmark.triangle" : "lock.shield")
|
||||
configuration.text = page.stateTitle
|
||||
configuration.secondaryText = page.stateDetail
|
||||
contentUnavailableConfiguration = configuration
|
||||
return
|
||||
}
|
||||
showLoading()
|
||||
}
|
||||
|
||||
private func finish(
|
||||
_ result: Result<MobilePasswordPage, PasswordFailure>,
|
||||
selectedID: String?
|
||||
) {
|
||||
refreshControl?.endRefreshing()
|
||||
switch result {
|
||||
case let .success(page):
|
||||
directoryPage = page
|
||||
title = page.title
|
||||
contentUnavailableConfiguration = nil
|
||||
tableView.reloadData()
|
||||
if page.rows.isEmpty {
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "folder")
|
||||
configuration.text = "Empty Folder"
|
||||
configuration.secondaryText = "This password folder contains no entries."
|
||||
contentUnavailableConfiguration = configuration
|
||||
} else if let selectedID,
|
||||
let row = page.rows.firstIndex(where: { $0.id == selectedID }) {
|
||||
tableView.selectRow(at: IndexPath(row: row, section: 0), animated: false, scrollPosition: .none)
|
||||
}
|
||||
case let .failure(failure):
|
||||
if failure.kind == .directoryMissing, path != nil {
|
||||
UIAccessibility.post(
|
||||
notification: .announcement,
|
||||
argument: "The password folder no longer exists."
|
||||
)
|
||||
navigationController?.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
directoryPage = nil
|
||||
tableView.reloadData()
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "exclamationmark.triangle")
|
||||
configuration.text = failure.title
|
||||
configuration.secondaryText = failure.detail
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
}
|
||||
|
||||
private func showLoading() {
|
||||
var configuration = UIContentUnavailableConfiguration.loading()
|
||||
configuration.text = "Loading Passwords"
|
||||
configuration.secondaryText = "Reading the locked folder model from storage."
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
}
|
||||
|
||||
private struct PasswordFailure: Error, Sendable {
|
||||
let kind: MobilePasswordErrorKind
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
init(_ error: MobilePasswordFfiError) {
|
||||
switch error {
|
||||
case let .Failed(kind, title, detail):
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
static let unexpected = PasswordFailure(
|
||||
kind: .repository,
|
||||
title: "Passwords Are Unavailable",
|
||||
detail: "IronStorage could not load the storage-provided folder."
|
||||
)
|
||||
|
||||
private init(kind: MobilePasswordErrorKind, title: String, detail: String) {
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LockedPasswordViewController: UIViewController {
|
||||
private let entry: MobilePasswordRow
|
||||
|
||||
init(entry: MobilePasswordRow) {
|
||||
self.entry = entry
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = entry.title
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemGroupedBackground
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "lock.fill")
|
||||
configuration.text = "Locked Password"
|
||||
configuration.secondaryText = "Authenticate to reveal this password entry."
|
||||
contentUnavailableConfiguration = configuration
|
||||
view.accessibilityIdentifier = entry.id
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeFailure: Error, Sendable {
|
||||
let kind: MobileHomeErrorKind
|
||||
let title: String
|
||||
|
||||
Reference in New Issue
Block a user