Implement structured iPhone entry viewer
This commit is contained in:
@@ -26,6 +26,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
self.window = window
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
context?.lockForBackground()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -73,6 +77,15 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||
return tabs
|
||||
}
|
||||
|
||||
func lockForBackground() {
|
||||
guard let authentication else { return }
|
||||
try? authentication.manualLock()
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
}
|
||||
|
||||
func tabBarController(
|
||||
_ tabBarController: UITabBarController,
|
||||
didSelect viewController: UIViewController
|
||||
@@ -275,7 +288,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
guard shellTab == .home, let homePage else { return }
|
||||
let section = homeSections[indexPath.section]
|
||||
let commit: MobileHomeCommit
|
||||
@@ -1142,20 +1155,21 @@ private struct PasswordFailure: Error, Sendable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LockedPasswordViewController: UIViewController {
|
||||
private final class LockedPasswordViewController: UITableViewController {
|
||||
private let entry: MobilePasswordRow
|
||||
private let authentication: MobileAuthentication?
|
||||
private let imageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let unlockButton = UIButton(type: .system)
|
||||
private var unlockTask: Task<Void, Never>?
|
||||
private var entryTask: Task<Void, Never>?
|
||||
private var clipboardTask: Task<Void, Never>?
|
||||
private var feedbackTask: Task<Void, Never>?
|
||||
private var state: MobileAuthenticationState?
|
||||
private var page: MobileEntryPage?
|
||||
private var revealedValues: [UInt64: String] = [:]
|
||||
|
||||
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
||||
self.entry = entry
|
||||
self.authentication = authentication
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
super.init(style: .insetGrouped)
|
||||
title = entry.title
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
}
|
||||
@@ -1167,34 +1181,9 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemGroupedBackground
|
||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44)
|
||||
imageView.tintColor = .secondaryLabel
|
||||
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
titleLabel.adjustsFontForContentSizeCategory = true
|
||||
titleLabel.textAlignment = .center
|
||||
detailLabel.font = .preferredFont(forTextStyle: .body)
|
||||
detailLabel.adjustsFontForContentSizeCategory = true
|
||||
detailLabel.textAlignment = .center
|
||||
detailLabel.textColor = .secondaryLabel
|
||||
detailLabel.numberOfLines = 0
|
||||
unlockButton.configuration = .filled()
|
||||
unlockButton.configuration?.cornerStyle = .capsule
|
||||
unlockButton.addTarget(self, action: #selector(unlockRequested), for: .touchUpInside)
|
||||
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, detailLabel, unlockButton])
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = 12
|
||||
stack.setCustomSpacing(24, after: detailLabel)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||
stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor),
|
||||
detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 360),
|
||||
unlockButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
|
||||
])
|
||||
tableView.register(MobileEntryFieldCell.self, forCellReuseIdentifier: "EntryField")
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 92
|
||||
view.accessibilityIdentifier = entry.id
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
@@ -1202,11 +1191,14 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: nil
|
||||
)
|
||||
render()
|
||||
refreshState()
|
||||
}
|
||||
|
||||
deinit {
|
||||
unlockTask?.cancel()
|
||||
entryTask?.cancel()
|
||||
clipboardTask?.cancel()
|
||||
feedbackTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@@ -1215,23 +1207,59 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
refreshState()
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
page?.sections.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
numberOfRowsInSection section: Int
|
||||
) -> Int {
|
||||
page?.sections[section].fields.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
titleForHeaderInSection section: Int
|
||||
) -> String? {
|
||||
page?.sections[section].title
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
guard
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: "EntryField",
|
||||
for: indexPath
|
||||
) as? MobileEntryFieldCell,
|
||||
let field = field(at: indexPath)
|
||||
else { return UITableViewCell() }
|
||||
cell.configure(
|
||||
field: field,
|
||||
revealedValue: revealedValues[field.id],
|
||||
copy: { [weak self] tappedCell in self?.copy(field, in: tappedCell) },
|
||||
reveal: { [weak self] in self?.revealOrHide(field) },
|
||||
edit: { [weak self] in self?.edit(field) }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard
|
||||
let field = field(at: indexPath),
|
||||
let cell = tableView.cellForRow(at: indexPath) as? MobileEntryFieldCell
|
||||
else { return }
|
||||
copy(field, in: cell)
|
||||
}
|
||||
|
||||
@objc private func authenticationDidChange() {
|
||||
refreshState()
|
||||
}
|
||||
|
||||
@objc private func unlockRequested() {
|
||||
if state?.unlocked == true, let authentication {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
refreshState()
|
||||
UIAccessibility.post(notification: .announcement, argument: "Unlock extended")
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
presentAuthenticationFailure(.unexpected)
|
||||
}
|
||||
return
|
||||
}
|
||||
unlock(passphrase: nil)
|
||||
}
|
||||
|
||||
@@ -1244,7 +1272,6 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
render()
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
@@ -1254,7 +1281,12 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
|
||||
private func refreshState() {
|
||||
state = try? authentication?.state()
|
||||
render()
|
||||
if state?.unlocked == true {
|
||||
if page == nil { loadEntry() }
|
||||
} else {
|
||||
maskAndDiscardEntry()
|
||||
showLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private func unlock(passphrase: String?) {
|
||||
@@ -1263,8 +1295,7 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
return
|
||||
}
|
||||
unlockTask?.cancel()
|
||||
unlockButton.isEnabled = false
|
||||
unlockButton.configuration?.showsActivityIndicator = true
|
||||
showLoading(title: "Unlocking Password")
|
||||
let path = entry.path
|
||||
unlockTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
@@ -1279,8 +1310,6 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
unlockButton.isEnabled = true
|
||||
unlockButton.configuration?.showsActivityIndicator = false
|
||||
switch result {
|
||||
case let .success(state):
|
||||
self.state = state
|
||||
@@ -1288,7 +1317,7 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
render()
|
||||
loadEntry()
|
||||
UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
|
||||
case let .failure(failure):
|
||||
if passphrase == nil,
|
||||
@@ -1296,6 +1325,7 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
promptForPassphrase(message: failure.detail)
|
||||
} else if failure.kind != .cancelled {
|
||||
presentAuthenticationFailure(failure)
|
||||
showLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1324,28 +1354,440 @@ private final class LockedPasswordViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func render() {
|
||||
let unlocked = state?.unlocked == true
|
||||
imageView.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
|
||||
titleLabel.text = unlocked ? "Key Unlocked" : "Locked Password"
|
||||
detailLabel.text = if unlocked {
|
||||
"The GPG key is available for protected operations for up to \(state?.remainingSeconds ?? 0) seconds."
|
||||
} else {
|
||||
"Authenticate to unlock this password entry. Browsing remains available while locked."
|
||||
private func loadEntry() {
|
||||
guard let authentication else {
|
||||
showLocked()
|
||||
return
|
||||
}
|
||||
unlockButton.configuration?.title = unlocked ? "Extend Unlock" : "Unlock"
|
||||
navigationItem.rightBarButtonItem = unlocked
|
||||
? UIBarButtonItem(
|
||||
image: UIImage(systemName: "lock.fill"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(lockRequested)
|
||||
entryTask?.cancel()
|
||||
showLoading(title: "Opening Password")
|
||||
let path = entry.path
|
||||
entryTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
return Result<MobileEntryPage, AuthenticationFailure>.success(
|
||||
try authentication.entryPage(path: path)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case let .success(page):
|
||||
self.page = page
|
||||
title = page.title
|
||||
contentUnavailableConfiguration = nil
|
||||
installLockButton()
|
||||
tableView.reloadData()
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func field(at indexPath: IndexPath) -> MobileEntryField? {
|
||||
guard
|
||||
let page,
|
||||
page.sections.indices.contains(indexPath.section),
|
||||
page.sections[indexPath.section].fields.indices.contains(indexPath.row)
|
||||
else { return nil }
|
||||
return page.sections[indexPath.section].fields[indexPath.row]
|
||||
}
|
||||
|
||||
private func revealOrHide(_ field: MobileEntryField) {
|
||||
if revealedValues.removeValue(forKey: field.id) != nil {
|
||||
tableView.reloadData()
|
||||
UIAccessibility.post(notification: .announcement, argument: "\(field.label) hidden")
|
||||
return
|
||||
}
|
||||
performFieldAction(field) { authentication, path in
|
||||
try authentication.revealEntryField(path: path, field: field.id)
|
||||
} success: { [weak self] value in
|
||||
self?.revealedValues[field.id] = value
|
||||
self?.tableView.reloadData()
|
||||
UIAccessibility.post(notification: .announcement, argument: "\(field.label) revealed")
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(_ field: MobileEntryField, in cell: MobileEntryFieldCell?) {
|
||||
cell?.flashCopied()
|
||||
performFieldAction(field) { authentication, path in
|
||||
try authentication.copyEntryField(path: path, field: field.id)
|
||||
} success: { [weak self] copy in
|
||||
guard let self else { return }
|
||||
UIPasteboard.general.string = copy.value
|
||||
showFeedback("\(field.label) copied")
|
||||
UIAccessibility.post(notification: .announcement, argument: "\(field.label) copied")
|
||||
clipboardTask?.cancel()
|
||||
clipboardTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(copy.timeoutSeconds))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if UIPasteboard.general.string == copy.value {
|
||||
UIPasteboard.general.items = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func edit(_ field: MobileEntryField) {
|
||||
performFieldAction(field) { authentication, path in
|
||||
try authentication.revealEntryField(path: path, field: field.id)
|
||||
} success: { [weak self] value in
|
||||
guard let self else { return }
|
||||
let editor = MobileEntryEditorViewController(field: field, value: value) {
|
||||
[weak self] updated in self?.save(updated, for: field)
|
||||
}
|
||||
present(UINavigationController(rootViewController: editor), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func save(_ value: String, for field: MobileEntryField) {
|
||||
guard let authentication else { return }
|
||||
entryTask?.cancel()
|
||||
showLoading(title: "Saving Password")
|
||||
let path = entry.path
|
||||
entryTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<MobileEntryPage, AuthenticationFailure>.success(
|
||||
try authentication.replaceEntryField(
|
||||
path: path,
|
||||
field: field.id,
|
||||
value: value
|
||||
)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case let .success(page):
|
||||
self.page = page
|
||||
revealedValues.removeValue(forKey: field.id)
|
||||
contentUnavailableConfiguration = nil
|
||||
tableView.reloadData()
|
||||
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
|
||||
showFeedback("\(field.label) saved")
|
||||
case let .failure(failure):
|
||||
handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performFieldAction<Value: Sendable>(
|
||||
_ field: MobileEntryField,
|
||||
operation: @escaping @Sendable (MobileAuthentication, String) throws -> Value,
|
||||
success: @escaping @MainActor (Value) -> Void
|
||||
) {
|
||||
guard let authentication else {
|
||||
presentAuthenticationFailure(.unavailable)
|
||||
return
|
||||
}
|
||||
let path = entry.path
|
||||
Task { [weak self] in
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try authentication.touchUserActivity()
|
||||
return Result<Value, AuthenticationFailure>.success(
|
||||
try operation(authentication, path)
|
||||
)
|
||||
} catch let error as MobileAuthenticationFfiError {
|
||||
return .failure(AuthenticationFailure(error))
|
||||
} catch {
|
||||
return .failure(.unexpected)
|
||||
}
|
||||
}.value
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
switch result {
|
||||
case let .success(value): success(value)
|
||||
case let .failure(failure): handle(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ failure: AuthenticationFailure) {
|
||||
if failure.kind == .expired {
|
||||
state = try? authentication?.state()
|
||||
maskAndDiscardEntry()
|
||||
showLocked()
|
||||
NotificationCenter.default.post(
|
||||
name: .ironStorageAuthenticationDidChange,
|
||||
object: authentication
|
||||
)
|
||||
: nil
|
||||
}
|
||||
presentAuthenticationFailure(failure)
|
||||
}
|
||||
|
||||
private func maskAndDiscardEntry() {
|
||||
entryTask?.cancel()
|
||||
feedbackTask?.cancel()
|
||||
page = nil
|
||||
revealedValues.removeAll(keepingCapacity: false)
|
||||
tableView.reloadData()
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
navigationItem.prompt = nil
|
||||
title = entry.title
|
||||
}
|
||||
|
||||
private func showLocked() {
|
||||
var configuration = UIContentUnavailableConfiguration.empty()
|
||||
configuration.image = UIImage(systemName: "lock.fill")
|
||||
configuration.text = "Locked Password"
|
||||
configuration.secondaryText =
|
||||
"Authenticate to decrypt this entry. Browsing remains available while locked."
|
||||
configuration.button = .filled()
|
||||
configuration.button.title = "Unlock"
|
||||
configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in
|
||||
self?.unlockRequested()
|
||||
}
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
private func showLoading(title: String) {
|
||||
var configuration = UIContentUnavailableConfiguration.loading()
|
||||
configuration.text = title
|
||||
configuration.secondaryText = "Reading the structured entry from secure storage."
|
||||
contentUnavailableConfiguration = configuration
|
||||
}
|
||||
|
||||
private func showFeedback(_ message: String) {
|
||||
feedbackTask?.cancel()
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "checkmark.circle.fill"),
|
||||
style: .plain,
|
||||
target: nil,
|
||||
action: nil
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = message
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
feedbackTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard let self else { return }
|
||||
if page != nil { installLockButton() }
|
||||
}
|
||||
}
|
||||
|
||||
private func installLockButton() {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "lock.fill"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(lockRequested)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
|
||||
unlockButton.accessibilityHint = unlocked
|
||||
? "Extends access after authentication."
|
||||
: "Requests biometric authentication or the GPG key passphrase."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MobileEntryFieldCell: UITableViewCell {
|
||||
private let iconView = UIImageView()
|
||||
private let labelView = UILabel()
|
||||
private let valueView = UITextView()
|
||||
private let detailView = UILabel()
|
||||
private let diagnosticView = UILabel()
|
||||
private let revealButton = UIButton(type: .system)
|
||||
private let editButton = UIButton(type: .system)
|
||||
private var copyValue: ((MobileEntryFieldCell) -> Void)?
|
||||
private var highlightTask: Task<Void, Never>?
|
||||
|
||||
private static let revealAction = UIAction.Identifier("reveal-entry-field")
|
||||
private static let editAction = UIAction.Identifier("edit-entry-field")
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .default
|
||||
iconView.tintColor = .secondaryLabel
|
||||
iconView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
labelView.font = .preferredFont(forTextStyle: .caption1)
|
||||
labelView.textColor = .secondaryLabel
|
||||
labelView.adjustsFontForContentSizeCategory = true
|
||||
valueView.font = .preferredFont(forTextStyle: .body)
|
||||
valueView.adjustsFontForContentSizeCategory = true
|
||||
valueView.backgroundColor = .clear
|
||||
valueView.isEditable = false
|
||||
valueView.isScrollEnabled = false
|
||||
valueView.textContainerInset = .zero
|
||||
valueView.textContainer.lineFragmentPadding = 0
|
||||
let valueTap = UITapGestureRecognizer(target: self, action: #selector(valueTapped))
|
||||
valueTap.cancelsTouchesInView = false
|
||||
valueView.addGestureRecognizer(valueTap)
|
||||
detailView.font = .preferredFont(forTextStyle: .footnote)
|
||||
detailView.textColor = .secondaryLabel
|
||||
detailView.adjustsFontForContentSizeCategory = true
|
||||
detailView.numberOfLines = 0
|
||||
diagnosticView.font = .preferredFont(forTextStyle: .footnote)
|
||||
diagnosticView.textColor = .systemOrange
|
||||
diagnosticView.adjustsFontForContentSizeCategory = true
|
||||
diagnosticView.numberOfLines = 0
|
||||
revealButton.configuration = .plain()
|
||||
editButton.configuration = .plain()
|
||||
editButton.configuration?.image = UIImage(systemName: "pencil")
|
||||
editButton.accessibilityLabel = "Edit field"
|
||||
let labels = UIStackView(arrangedSubviews: [labelView, valueView, detailView, diagnosticView])
|
||||
labels.axis = .vertical
|
||||
labels.spacing = 3
|
||||
labels.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
let actions = UIStackView(arrangedSubviews: [revealButton, editButton])
|
||||
actions.distribution = .fillEqually
|
||||
NSLayoutConstraint.activate([
|
||||
actions.widthAnchor.constraint(equalToConstant: 88),
|
||||
actions.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||
])
|
||||
actions.setContentHuggingPriority(.required, for: .horizontal)
|
||||
actions.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
let row = UIStackView(arrangedSubviews: [iconView, labels, actions])
|
||||
row.alignment = .top
|
||||
row.spacing = 12
|
||||
row.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(row)
|
||||
NSLayoutConstraint.activate([
|
||||
iconView.widthAnchor.constraint(equalToConstant: 24),
|
||||
iconView.heightAnchor.constraint(equalToConstant: 24),
|
||||
row.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
row.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
row.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
|
||||
row.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
func configure(
|
||||
field: MobileEntryField,
|
||||
revealedValue: String?,
|
||||
copy: @escaping (MobileEntryFieldCell) -> Void,
|
||||
reveal: @escaping () -> Void,
|
||||
edit: @escaping () -> Void
|
||||
) {
|
||||
let value = revealedValue ?? field.value ?? field.maskedValue
|
||||
iconView.image = UIImage(systemName: field.systemImage)
|
||||
labelView.text = field.label
|
||||
valueView.text = value
|
||||
copyValue = copy
|
||||
valueView.textColor = field.sensitive && revealedValue == nil ? .secondaryLabel : .label
|
||||
valueView.isSelectable = field.selectable
|
||||
detailView.text = field.detail
|
||||
detailView.isHidden = field.detail == nil
|
||||
diagnosticView.text = field.diagnostic
|
||||
diagnosticView.isHidden = field.diagnostic == nil
|
||||
revealButton.configuration?.image = UIImage(
|
||||
systemName: revealedValue == nil ? "eye" : "eye.slash"
|
||||
)
|
||||
revealButton.accessibilityLabel = revealedValue == nil ? "Reveal field" : "Hide field"
|
||||
revealButton.removeAction(identifiedBy: Self.revealAction, for: .touchUpInside)
|
||||
revealButton.addAction(
|
||||
UIAction(identifier: Self.revealAction) { _ in reveal() },
|
||||
for: .touchUpInside
|
||||
)
|
||||
revealButton.alpha = field.sensitive ? 1 : 0
|
||||
revealButton.isEnabled = field.sensitive
|
||||
revealButton.accessibilityElementsHidden = !field.sensitive
|
||||
editButton.removeAction(identifiedBy: Self.editAction, for: .touchUpInside)
|
||||
editButton.addAction(
|
||||
UIAction(identifier: Self.editAction) { _ in edit() },
|
||||
for: .touchUpInside
|
||||
)
|
||||
editButton.alpha = field.editable ? 1 : 0
|
||||
editButton.isEnabled = field.editable
|
||||
editButton.accessibilityElementsHidden = !field.editable
|
||||
accessibilityLabel = [field.label, value, field.detail, field.diagnostic]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: ", ")
|
||||
accessibilityHint = field.sensitive
|
||||
? "Double tap the row to copy. Reveal and Edit buttons follow."
|
||||
: "Double tap the row to copy. An Edit button follows."
|
||||
}
|
||||
|
||||
func flashCopied() {
|
||||
highlightTask?.cancel()
|
||||
setHighlighted(true, animated: false)
|
||||
highlightTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(350))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
self?.setHighlighted(false, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
highlightTask?.cancel()
|
||||
setHighlighted(false, animated: false)
|
||||
}
|
||||
|
||||
@objc private func valueTapped() {
|
||||
copyValue?(self)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MobileEntryEditorViewController: UIViewController {
|
||||
private let field: MobileEntryField
|
||||
private let valueView = UITextView()
|
||||
private let save: (String) -> Void
|
||||
|
||||
init(field: MobileEntryField, value: String, save: @escaping (String) -> Void) {
|
||||
self.field = field
|
||||
self.save = save
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
valueView.text = value
|
||||
title = "Edit \(field.label)"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemGroupedBackground
|
||||
valueView.font = .preferredFont(forTextStyle: .body)
|
||||
valueView.adjustsFontForContentSizeCategory = true
|
||||
valueView.autocorrectionType = field.sensitive ? .no : .default
|
||||
valueView.autocapitalizationType = field.sensitive ? .none : .sentences
|
||||
valueView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(valueView)
|
||||
NSLayoutConstraint.activate([
|
||||
valueView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
valueView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
|
||||
valueView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
||||
valueView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16),
|
||||
])
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
systemItem: .cancel,
|
||||
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
|
||||
)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
systemItem: .save,
|
||||
primaryAction: UIAction { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let value = valueView.text ?? ""
|
||||
dismiss(animated: true) { self.save(value) }
|
||||
}
|
||||
)
|
||||
valueView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user