Build native iPhone application shell
This commit is contained in:
@@ -1,20 +1,195 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct IronStorageApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
private var context: AppContext?
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
let context = AppContext()
|
||||
self.context = context
|
||||
window.rootViewController = context.makeRootController()
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
private let tabs = UITabBarController()
|
||||
private var navigationControllers: [UINavigationController] = []
|
||||
private var restoreTask: Task<Void, Never>?
|
||||
|
||||
func makeRootController() -> UIViewController {
|
||||
let shell = mobileShellFixture(state: .loading)
|
||||
navigationControllers = shell.pages.map { page in
|
||||
let root = ShellViewController(page: page)
|
||||
let navigation = UINavigationController(rootViewController: root)
|
||||
navigation.navigationBar.prefersLargeTitles = true
|
||||
navigation.tabBarItem = UITabBarItem(
|
||||
title: page.title,
|
||||
image: UIImage(systemName: page.systemImage),
|
||||
selectedImage: UIImage(systemName: page.selectedSystemImage)
|
||||
)
|
||||
return navigation
|
||||
}
|
||||
tabs.viewControllers = navigationControllers
|
||||
tabs.delegate = self
|
||||
restoreSelectedTab()
|
||||
return tabs
|
||||
}
|
||||
|
||||
func tabBarController(
|
||||
_ tabBarController: UITabBarController,
|
||||
didSelect viewController: UIViewController
|
||||
) {
|
||||
guard
|
||||
let navigation = viewController as? UINavigationController,
|
||||
let root = navigation.viewControllers.first as? ShellViewController
|
||||
else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try setSelectedMobileTab(tab: root.shellTab)
|
||||
} catch {
|
||||
let alert = UIAlertController(
|
||||
title: "Selection Was Not Saved",
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
tabs.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreSelectedTab() {
|
||||
restoreTask?.cancel()
|
||||
restoreTask = Task { [weak self] in
|
||||
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
if let index = shell.pages.firstIndex(where: { $0.tab == shell.selectedTab }) {
|
||||
tabs.selectedIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentView: View {
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
"No Password Store",
|
||||
systemImage: "lock.shield",
|
||||
description: Text("Open or clone a store in \(productName()) to begin.")
|
||||
)
|
||||
@MainActor
|
||||
private final class ShellViewController: UITableViewController {
|
||||
fileprivate let shellTab: MobileTab
|
||||
private var page: MobilePage
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var loadGeneration = 0
|
||||
|
||||
init(page: MobilePage) {
|
||||
shellTab = page.tab
|
||||
self.page = page
|
||||
super.init(style: .insetGrouped)
|
||||
title = page.title
|
||||
navigationItem.largeTitleDisplayMode = .always
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
apply(page)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
reloadShell()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
loadTask?.cancel()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
page.state == .ready ? 1 : 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.image = UIImage(systemName: page.systemImage)
|
||||
content.text = page.stateTitle
|
||||
content.secondaryText = page.stateDetail
|
||||
content.secondaryTextProperties.numberOfLines = 0
|
||||
cell.contentConfiguration = content
|
||||
cell.selectionStyle = .none
|
||||
cell.accessibilityLabel = "\(page.stateTitle). \(page.stateDetail)"
|
||||
return cell
|
||||
}
|
||||
|
||||
@objc private func refreshRequested() {
|
||||
reloadShell()
|
||||
}
|
||||
|
||||
private func reloadShell() {
|
||||
loadGeneration += 1
|
||||
let generation = loadGeneration
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { [weak self] in
|
||||
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
||||
guard
|
||||
!Task.isCancelled,
|
||||
let self,
|
||||
generation == loadGeneration,
|
||||
let page = shell.pages.first(where: { $0.tab == self.shellTab })
|
||||
else { return }
|
||||
apply(page)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(_ page: MobilePage) {
|
||||
self.page = page
|
||||
title = page.title
|
||||
refreshControl?.endRefreshing()
|
||||
tableView.reloadData()
|
||||
|
||||
guard page.state != .ready else {
|
||||
contentUnavailableConfiguration = nil
|
||||
return
|
||||
}
|
||||
var configuration = page.state == .loading
|
||||
? UIContentUnavailableConfiguration.loading()
|
||||
: UIContentUnavailableConfiguration.empty()
|
||||
configuration.text = page.stateTitle
|
||||
configuration.secondaryText = page.stateDetail
|
||||
configuration.image = UIImage(systemName: stateImage(page.state))
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
private func stateImage(_ state: MobileShellState) -> String {
|
||||
switch state {
|
||||
case .loading: "hourglass"
|
||||
case .empty: "lock.shield"
|
||||
case .ready: page.systemImage
|
||||
case .locked: "lock.fill"
|
||||
case .error: "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user