Implement native iPhone entry editor

This commit is contained in:
2026-08-11 20:27:14 +02:00
parent 900e62e523
commit ae64ce47a3
8 changed files with 2516 additions and 76 deletions

View File

@@ -896,6 +896,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
private var shellPage: MobilePage?
private var directoryPage: MobilePasswordPage?
private var loadTask: Task<Void, Never>?
private var createTask: Task<Void, Never>?
private var loadGeneration = 0
init(
@@ -943,6 +944,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
deinit {
loadTask?.cancel()
createTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
@@ -1020,6 +1022,100 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
reload()
}
@objc private func createRequested() {
let alert = UIAlertController(
title: "New Password",
message: "Enter a name for the password entry in this folder.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Entry Name"
field.autocapitalizationType = .words
field.clearButtonMode = .whileEditing
field.returnKeyType = .next
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
self?.beginCreate(name: name, passphrase: nil)
})
present(alert, animated: true)
}
private func beginCreate(name: String, passphrase: String?) {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
createTask?.cancel()
let directory = path ?? ""
createTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileEntryEditorSession, AuthenticationFailure>.success(
try authentication.beginCreateEntry(
directory: directory,
name: name,
passphrase: passphrase
)
)
} 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(session):
presentEditor(session, focusedField: nil)
case let .failure(failure)
where passphrase == nil
&& (failure.kind == .passphraseRequired
|| failure.kind == .biometryUnavailable):
promptForCreationPassphrase(name: name, message: failure.detail)
case let .failure(failure):
presentAuthenticationFailure(failure)
}
}
}
private func promptForCreationPassphrase(name: String, message: String) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
alert?.textFields?.first?.text = nil
self?.beginCreate(name: name, passphrase: value)
})
present(alert, animated: true)
}
private func presentEditor(_ session: MobileEntryEditorSession, focusedField: UInt64?) {
guard let authentication else { return }
let editor = MobileEntryEditorViewController(
authentication: authentication,
session: session,
focusedField: focusedField
) { [weak self] _ in
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
self?.reload()
}
let navigation = UINavigationController(rootViewController: editor)
navigation.modalPresentationStyle = .formSheet
present(navigation, animated: true)
}
private func reload() {
loadGeneration += 1
let generation = loadGeneration
@@ -1063,6 +1159,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
title = page.title
guard page.state == .ready else {
directoryPage = nil
navigationItem.rightBarButtonItem = nil
tableView.reloadData()
refreshControl?.endRefreshing()
var configuration = page.state == .loading
@@ -1088,6 +1185,12 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
case let .success(page):
directoryPage = page
title = page.title
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createRequested)
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "Create password entry"
contentUnavailableConfiguration = nil
tableView.reloadData()
if page.rows.isEmpty {
@@ -1101,6 +1204,7 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
tableView.selectRow(at: IndexPath(row: row, section: 0), animated: false, scrollPosition: .none)
}
case let .failure(failure):
navigationItem.rightBarButtonItem = nil
if failure.kind == .directoryMissing, path != nil {
UIAccessibility.post(
notification: .announcement,
@@ -1436,32 +1540,15 @@ private final class LockedPasswordViewController: UITableViewController {
}
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
)
return Result<MobileEntryEditorSession, AuthenticationFailure>.success(
try authentication.beginEntryEditor(path: path)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
@@ -1471,13 +1558,22 @@ private final class LockedPasswordViewController: UITableViewController {
}.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 .success(session):
let editor = MobileEntryEditorViewController(
authentication: authentication,
session: session,
focusedField: field.id
) { [weak self] _ in
NotificationCenter.default.post(
name: .ironStorageLocalStoreDidChange,
object: nil
)
self?.revealedValues.removeAll(keepingCapacity: false)
self?.loadEntry()
}
let navigation = UINavigationController(rootViewController: editor)
navigation.modalPresentationStyle = .formSheet
present(navigation, animated: true)
case let .failure(failure):
handle(failure)
}
@@ -1742,17 +1838,37 @@ private final class MobileEntryFieldCell: UITableViewCell {
}
@MainActor
private final class MobileEntryEditorViewController: UIViewController {
private let field: MobileEntryField
private let valueView = UITextView()
private let save: (String) -> Void
private final class MobileEntryEditorViewController: UITableViewController,
UIAdaptivePresentationControllerDelegate
{
private struct FieldState {
var name: String?
var value: String?
}
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)"
private let authentication: MobileAuthentication
private var editorID: UInt64
private var page: MobileEntryEditorPage
private let focusedField: UInt64?
private let saved: (String) -> Void
private var states: [UInt64: FieldState] = [:]
private var localDirty = false
private var focused = false
private var operationTask: Task<Void, Never>?
init(
authentication: MobileAuthentication,
session: MobileEntryEditorSession,
focusedField: UInt64?,
saved: @escaping (String) -> Void
) {
self.authentication = authentication
editorID = session.id
page = session.page
self.focusedField = focusedField
self.saved = saved
super.init(style: .insetGrouped)
resetStates()
}
@available(*, unavailable)
@@ -1760,34 +1876,590 @@ private final class MobileEntryEditorViewController: UIViewController {
fatalError("init(coder:) is not supported")
}
deinit {
operationTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
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),
])
title = page.creating ? "New \(page.title)" : "Edit \(page.title)"
navigationItem.largeTitleDisplayMode = .never
tableView.register(MobileEntryEditorCell.self, forCellReuseIdentifier: "EditorField")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.setEditing(true, animated: false)
navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }
primaryAction: UIAction { [weak self] _ in self?.cancelRequested() }
)
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) }
}
primaryAction: UIAction { [weak self] _ in self?.saveRequested() }
)
valueView.becomeFirstResponder()
let add = UIBarButtonItem(
systemItem: .add,
primaryAction: UIAction { [weak self] _ in self?.addRequested() }
)
add.accessibilityLabel = "Add entry field"
let generate = UIBarButtonItem(
title: "Generate Password",
image: UIImage(systemName: "wand.and.stars"),
primaryAction: UIAction { [weak self] _ in self?.generateRequested() }
)
toolbarItems = [
add,
UIBarButtonItem(systemItem: .flexibleSpace),
generate,
]
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setToolbarHidden(false, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.presentationController?.delegate = self
guard !focused, let focusedField,
let index = page.fields.firstIndex(where: { $0.id == focusedField })
else { return }
focused = true
let path = IndexPath(row: index, section: 0)
tableView.scrollToRow(at: path, at: .middle, animated: true)
(tableView.cellForRow(at: path) as? MobileEntryEditorCell)?.focusValue()
}
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
page.fields.count
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
"Entry Fields"
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
"Drag the reorder controls to preserve the exact field order saved by Rust."
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: "EditorField",
for: indexPath
) as? MobileEntryEditorCell else { return UITableViewCell() }
let field = page.fields[indexPath.row]
let state = states[field.id] ?? FieldState(name: field.name, value: field.value)
cell.configure(field: field, name: state.name, value: state.value) { [weak self] name, value in
self?.states[field.id] = FieldState(name: name, value: value)
self?.localDirty = true
}
cell.showsReorderControl = field.reorderable
return cell
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
page.fields[indexPath.row].removable
}
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
page.fields[indexPath.row].reorderable
}
override func tableView(
_ tableView: UITableView,
editingStyleForRowAt indexPath: IndexPath
) -> UITableViewCell.EditingStyle {
page.fields[indexPath.row].removable ? .delete : .none
}
override func tableView(
_ tableView: UITableView,
commit editingStyle: UITableViewCell.EditingStyle,
forRowAt indexPath: IndexPath
) {
guard editingStyle == .delete else { return }
do {
try synchronizeDraft()
page = try authentication.removeEntryEditorField(
editor: editorID,
field: page.fields[indexPath.row].id
)
resetStates()
tableView.deleteRows(at: [indexPath], with: .automatic)
} catch {
presentEditorFailure(error)
tableView.reloadData()
}
}
override func tableView(
_ tableView: UITableView,
moveRowAt sourceIndexPath: IndexPath,
to destinationIndexPath: IndexPath
) {
let field = page.fields[sourceIndexPath.row]
do {
try synchronizeDraft()
page = try authentication.reorderEntryEditorField(
editor: editorID,
field: field.id,
index: UInt32(destinationIndexPath.row)
)
resetStates()
tableView.reloadData()
} catch {
presentEditorFailure(error)
tableView.reloadData()
}
}
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
guard isDirty else {
try? authentication.discardEntryEditor(editor: editorID)
return true
}
return false
}
func presentationControllerDidAttemptToDismiss(
_ presentationController: UIPresentationController
) {
confirmDiscard()
}
@objc private func authenticationDidChange() {
guard (try? authentication.state().unlocked) != true else { return }
states = states.mapValues { _ in FieldState(name: nil, value: nil) }
tableView.visibleCells
.compactMap { $0 as? MobileEntryEditorCell }
.forEach { $0.clearSensitiveContent() }
dismiss(animated: true)
UIAccessibility.post(notification: .announcement, argument: "Entry editor locked")
}
private var isDirty: Bool { localDirty || page.dirty }
private func resetStates() {
states = Dictionary(uniqueKeysWithValues: page.fields.map {
($0.id, FieldState(name: $0.name, value: $0.value))
})
localDirty = false
}
private func inputs() -> [MobileEntryEditorInput] {
page.fields.map { field in
let state = states[field.id] ?? FieldState(name: field.name, value: field.value)
return MobileEntryEditorInput(id: field.id, name: state.name, value: state.value)
}
}
private func synchronizeDraft() throws {
view.endEditing(true)
try authentication.touchUserActivity()
page = try authentication.updateEntryEditor(editor: editorID, fields: inputs())
resetStates()
}
private func cancelRequested() {
if isDirty {
confirmDiscard()
} else {
discardAndDismiss()
}
}
private func confirmDiscard() {
let sheet = UIAlertController(
title: "Discard Changes?",
message: "Unsaved entry changes will be lost.",
preferredStyle: .actionSheet
)
sheet.addAction(UIAlertAction(title: "Discard Changes", style: .destructive) {
[weak self] _ in self?.discardAndDismiss()
})
sheet.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
sheet.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem
present(sheet, animated: true)
}
private func discardAndDismiss() {
try? authentication.discardEntryEditor(editor: editorID)
states.removeAll(keepingCapacity: false)
dismiss(animated: true)
}
private func addRequested() {
let sheet = UIAlertController(title: "Add Field", message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "Named Field", style: .default) { [weak self] _ in
self?.promptForNamedField()
})
sheet.addAction(UIAlertAction(title: "Notes", style: .default) { [weak self] _ in
self?.addField(kind: .note, name: nil, value: "")
})
sheet.addAction(UIAlertAction(title: "OTP URI", style: .default) { [weak self] _ in
self?.promptForOtpUri()
})
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
sheet.popoverPresentationController?.barButtonItem = toolbarItems?.first
present(sheet, animated: true)
}
private func promptForNamedField() {
let alert = UIAlertController(
title: "Named Field",
message: "Enter a field name. You can enter its value in the form.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "Field Name"
field.autocapitalizationType = .none
field.autocorrectionType = .no
field.clearButtonMode = .whileEditing
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
guard let name = alert?.textFields?.first?.text, !name.isEmpty else { return }
self?.addField(kind: .field, name: name, value: "")
})
present(alert, animated: true)
}
private func promptForOtpUri() {
let alert = UIAlertController(
title: "OTP URI",
message: "Enter the complete otpauth URI.",
preferredStyle: .alert
)
alert.addTextField { field in
field.placeholder = "otpauth://…"
field.keyboardType = .URL
field.autocapitalizationType = .none
field.autocorrectionType = .no
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self, weak alert] _ in
guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return }
self?.addField(kind: .otpUri, name: nil, value: value)
})
present(alert, animated: true)
}
private func addField(kind: MobileEntryEditorFieldKind, name: String?, value: String) {
do {
try synchronizeDraft()
page = try authentication.addEntryEditorField(
editor: editorID,
kind: kind,
name: name,
value: value
)
resetStates()
tableView.reloadData()
if !page.fields.isEmpty {
let path = IndexPath(row: page.fields.count - 1, section: 0)
tableView.scrollToRow(at: path, at: .bottom, animated: true)
}
} catch {
presentEditorFailure(error)
}
}
private func generateRequested() {
let alert = UIAlertController(
title: "Generate Password",
message: "Choose a validated length from 1 through \(page.maximumPasswordLength).",
preferredStyle: .alert
)
alert.addTextField { [defaultLength = page.defaultPasswordLength] field in
field.text = String(defaultLength)
field.keyboardType = .numberPad
field.clearButtonMode = .whileEditing
field.accessibilityLabel = "Password length"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "With Symbols", style: .default) { [weak self, weak alert] _ in
self?.generate(from: alert, noSymbols: false)
})
alert.addAction(UIAlertAction(title: "Letters & Numbers", style: .default) {
[weak self, weak alert] _ in self?.generate(from: alert, noSymbols: true)
})
present(alert, animated: true)
}
private func generate(from alert: UIAlertController?, noSymbols: Bool) {
guard let text = alert?.textFields?.first?.text,
let length = UInt32(text)
else {
let failure = UIAlertController(
title: "Invalid Password Length",
message: "Enter a whole number from 1 through \(page.maximumPasswordLength).",
preferredStyle: .alert
)
failure.addAction(UIAlertAction(title: "OK", style: .default))
present(failure, animated: true)
return
}
do {
try synchronizeDraft()
page = try authentication.generateEntryEditorPassword(
editor: editorID,
length: length,
noSymbols: noSymbols
)
resetStates()
tableView.reloadData()
UIAccessibility.post(notification: .announcement, argument: "Password generated")
} catch {
presentEditorFailure(error)
}
}
private func saveRequested() {
view.endEditing(true)
let fields = inputs()
let authentication = authentication
let editorID = editorID
navigationItem.leftBarButtonItem?.isEnabled = false
navigationItem.rightBarButtonItem?.isEnabled = false
operationTask?.cancel()
operationTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
try authentication.touchUserActivity()
return Result<MobileEntryPage, AuthenticationFailure>.success(
try authentication.saveEntryEditor(editor: editorID, fields: fields)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
navigationItem.leftBarButtonItem?.isEnabled = true
navigationItem.rightBarButtonItem?.isEnabled = true
switch result {
case let .success(savedPage):
states.removeAll(keepingCapacity: false)
dismiss(animated: true) { self.saved(savedPage.id) }
case let .failure(failure):
presentSaveFailure(failure)
}
}
}
private func presentSaveFailure(_ failure: AuthenticationFailure) {
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
if failure.kind == .conflict {
alert.addAction(UIAlertAction(title: "Reload Latest", style: .destructive) {
[weak self] _ in self?.reloadLatest()
})
}
alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
present(alert, animated: true)
}
private func reloadLatest() {
let path = page.path
try? authentication.discardEntryEditor(editor: editorID)
do {
let session = try authentication.beginEntryEditor(path: path)
editorID = session.id
page = session.page
resetStates()
tableView.reloadData()
} catch {
presentEditorFailure(error)
}
}
private func presentEditorFailure(_ error: Error) {
let failure = (error as? MobileAuthenticationFfiError).map(AuthenticationFailure.init)
?? .unexpected
presentAuthenticationFailure(failure)
}
}
@MainActor
private final class MobileEntryEditorCell: UITableViewCell, UITextViewDelegate {
private let iconView = UIImageView()
private let labelView = UILabel()
private let nameField = UITextField()
private let valueField = UITextField()
private let valueView = UITextView()
private let diagnosticView = UILabel()
private let revealButton = UIButton(type: .system)
private var changed: ((String?, String?) -> Void)?
private var currentName: String?
private var currentValue: String?
private var sensitive = false
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
iconView.tintColor = .secondaryLabel
iconView.translatesAutoresizingMaskIntoConstraints = false
labelView.font = .preferredFont(forTextStyle: .caption1)
labelView.textColor = .secondaryLabel
labelView.adjustsFontForContentSizeCategory = true
for field in [nameField, valueField] {
field.font = .preferredFont(forTextStyle: .body)
field.adjustsFontForContentSizeCategory = true
field.clearButtonMode = .whileEditing
field.addTarget(self, action: #selector(textFieldChanged(_:)), for: .editingChanged)
}
nameField.placeholder = "Field Name"
nameField.autocapitalizationType = .none
nameField.autocorrectionType = .no
valueView.font = .preferredFont(forTextStyle: .body)
valueView.adjustsFontForContentSizeCategory = true
valueView.backgroundColor = .clear
valueView.isScrollEnabled = false
valueView.textContainerInset = .zero
valueView.textContainer.lineFragmentPadding = 0
valueView.delegate = self
diagnosticView.font = .preferredFont(forTextStyle: .footnote)
diagnosticView.textColor = .systemOrange
diagnosticView.adjustsFontForContentSizeCategory = true
diagnosticView.numberOfLines = 0
revealButton.configuration = .plain()
revealButton.configuration?.image = UIImage(systemName: "eye")
revealButton.addAction(UIAction { [weak self] _ in self?.toggleReveal() }, for: .touchUpInside)
let heading = UIStackView(arrangedSubviews: [labelView, nameField])
heading.axis = .vertical
heading.spacing = 2
let body = UIStackView(arrangedSubviews: [heading, valueField, valueView, diagnosticView])
body.axis = .vertical
body.spacing = 6
let row = UIStackView(arrangedSubviews: [iconView, body, revealButton])
row.alignment = .top
row.spacing = 12
row.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(row)
NSLayoutConstraint.activate([
iconView.widthAnchor.constraint(equalToConstant: 24),
iconView.heightAnchor.constraint(equalToConstant: 24),
revealButton.widthAnchor.constraint(equalToConstant: 44),
revealButton.heightAnchor.constraint(equalToConstant: 44),
valueView.heightAnchor.constraint(greaterThanOrEqualToConstant: 72),
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: MobileEntryEditorField,
name: String?,
value: String?,
changed: @escaping (String?, String?) -> Void
) {
self.changed = changed
currentName = name
currentValue = value
sensitive = field.sensitive
iconView.image = UIImage(systemName: field.systemImage)
labelView.text = field.label
nameField.text = name
nameField.isHidden = !field.nameEditable
labelView.isHidden = field.nameEditable
let multiline = field.multiline || field.kind == .note
valueField.isHidden = multiline
valueView.isHidden = !multiline
valueField.text = value ?? field.maskedValue
valueView.text = value ?? field.maskedValue
let editable = value != nil
nameField.isEnabled = editable
valueField.isEnabled = editable
valueView.isEditable = editable
valueField.isSecureTextEntry = field.sensitive
valueField.textContentType = field.kind == .password ? .password : nil
valueField.keyboardType = field.kind == .otpUri ? .URL : .default
valueField.autocapitalizationType = field.sensitive ? .none : .sentences
valueField.autocorrectionType = field.sensitive ? .no : .default
valueView.autocapitalizationType = .sentences
diagnosticView.text = field.diagnostic
diagnosticView.isHidden = field.diagnostic == nil
revealButton.isHidden = !field.sensitive
revealButton.accessibilityLabel = "Reveal \(field.label)"
nameField.accessibilityLabel = "\(field.label) name"
valueField.accessibilityLabel = field.label
valueView.accessibilityLabel = field.label
}
func focusValue() {
if valueView.isHidden {
valueField.becomeFirstResponder()
} else {
valueView.becomeFirstResponder()
}
}
func clearSensitiveContent() {
currentValue = nil
valueField.text = nil
valueView.text = nil
}
override func prepareForReuse() {
super.prepareForReuse()
changed = nil
currentName = nil
currentValue = nil
valueField.isSecureTextEntry = false
}
@objc private func textFieldChanged(_ sender: UITextField) {
if sender === nameField {
currentName = sender.text
} else {
currentValue = sender.text
}
changed?(currentName, currentValue)
}
func textViewDidChange(_ textView: UITextView) {
currentValue = textView.text
changed?(currentName, currentValue)
}
private func toggleReveal() {
guard sensitive else { return }
valueField.isSecureTextEntry.toggle()
revealButton.configuration?.image = UIImage(
systemName: valueField.isSecureTextEntry ? "eye" : "eye.slash"
)
revealButton.accessibilityLabel = valueField.isSecureTextEntry
? "Reveal field"
: "Hide field"
}
}