Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/flyfun-forms/flyfun-forms/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,29 @@
}
}
},
"Add “%@” as new person" : {
"comment" : "People picker: create a person under the name typed in the search field when nobody matches.",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "„%@“ als neue Person hinzufügen"
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Añadir «%@» como nueva persona"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Ajouter « %@ » comme nouvelle personne"
}
}
}
},
"Add Person" : {
"localizations" : {
"de" : {
Expand Down
12 changes: 12 additions & 0 deletions app/flyfun-forms/flyfun-forms/Models/Person.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ final class Person {
return name.isEmpty ? String(localized: "New Person") : name
}

/// Nothing has been entered: the state of a person created by Add Person
/// and left without typing anything. The editor has no Cancel, so this is
/// how the add flows know to take such a person away again.
var isBlank: Bool {
func empty(_ value: String?) -> Bool {
(value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
return empty(firstName) && empty(lastName) && dateOfBirth == nil
&& empty(phone) && empty(email) && empty(address) && empty(placeOfBirth)
&& documentList.isEmpty
}

/// Most recent flight date across all crew and passenger flights.
var lastFlightDate: Date? {
let crewDates = (crewFlights ?? []).map(\.departureDate)
Expand Down
178 changes: 178 additions & 0 deletions app/flyfun-forms/flyfun-forms/Views/AddPersonMenu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import SwiftUI
import SwiftData
import UniformTypeIdentifiers

/// The ways to bring a person into the app one at a time.
enum AddPersonMethod: Equatable {
/// An empty person, to fill in by hand.
case blank
/// From a passport or ID card's machine-readable zone.
case scan
/// From the address book.
case contact
}

/// The Add Person / Scan Document / Import from Contact menu items, shared by
/// the People tab and the crew/passenger picker. They only record which method
/// was chosen; `addPersonFlows` runs it.
struct AddPersonMenuItems: View {
@Binding var request: AddPersonMethod?
var addPersonIdentifier = "addPersonButton"

var body: some View {
Button {
request = .blank
} label: {
Label("Add Person", systemImage: "person.badge.plus")
}
.accessibilityIdentifier(addPersonIdentifier)
Button {
request = .scan
} label: {
Label("Scan Document", systemImage: "doc.text.viewfinder")
}
Button {
request = .contact
} label: {
Label("Import from Contact", systemImage: "person.crop.rectangle")
}
}
}

extension View {
/// Runs the method `AddPersonMenuItems` put in `request` and hands over the
/// person it ends with: a new one, or an existing one the scan matched or
/// the contact was merged into.
///
/// Sheets hang off the view this modifies rather than off the menu, which
/// lives in a toolbar and can be rebuilt under an open sheet.
func addPersonFlows(
request: Binding<AddPersonMethod?>,
onPerson: @escaping (Person, AddPersonMethod) -> Void
) -> some View {
modifier(AddPersonFlows(request: request, onPerson: onPerson))
}
}

private struct AddPersonFlows: ViewModifier {
@Binding var request: AddPersonMethod?
let onPerson: (Person, AddPersonMethod) -> Void

@Environment(\.modelContext) private var modelContext

@State private var showContactPicker = false
@State private var pickedContact: ImportedContact?
@State private var importedContact: ImportedContact?
@State private var scanProcessingResult: MRZProcessingResult?
/// The person a sheet ended with, handed over once that sheet has gone:
/// the pickers call back before they dismiss, and presenting or pushing
/// while a sheet is still leaving is silently dropped.
@State private var pendingPerson: (person: Person, method: AddPersonMethod)?
#if os(iOS)
@State private var showingScanSheet = false
@State private var pendingScan: MRZScanResult?
#else
@State private var showFilePicker = false
@State private var imageOCR = ImageOCRManager()
@State private var showNoMRZAlert = false
#endif

func body(content: Content) -> some View {
content
.onChange(of: request) { _, method in
guard let method else { return }
request = nil
start(method)
}
#if os(iOS)
.sheet(isPresented: $showContactPicker, onDismiss: showPickedContact) {
ContactPickerSheet { contact in
pickedContact = ImportedContact(from: contact)
}
}
.sheet(isPresented: $showingScanSheet, onDismiss: processPendingScan) {
ScanDocumentSheet { result in
pendingScan = result
}
}
#else
.sheet(isPresented: $showContactPicker, onDismiss: showPickedContact) {
ContactSearchView { contact in
pickedContact = ImportedContact(from: contact)
}
}
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.pdf, .image],
allowsMultipleSelection: false
) { result in
if case .success(let urls) = result, let url = urls.first {
imageOCR.scan(url: url)
}
}
.onChange(of: imageOCR.status) { _, newStatus in
if newStatus == .success, let result = imageOCR.result {
scanProcessingResult = MRZResultProcessor.process(result, context: .standalone, modelContext: modelContext)
} else if newStatus == .noMRZFound {
showNoMRZAlert = true
}
}
.alert("No Document Found", isPresented: $showNoMRZAlert) {
Button("OK") {}
} message: {
Text("No machine-readable zone (MRZ) was found in the file. Try a clearer image or PDF of the passport page.")
}
#endif
.sheet(item: $importedContact, onDismiss: deliverPendingPerson) { contact in
ContactResolveView(contact: contact) { person in
pendingPerson = (person, .contact)
}
}
.sheet(item: $scanProcessingResult, onDismiss: deliverPendingPerson) { processing in
MRZResultActionView(
processingResult: processing,
onDismiss: { scanProcessingResult = nil },
onPersonSelected: { person in
pendingPerson = (person, .scan)
}
)
}
}

private func start(_ method: AddPersonMethod) {
switch method {
case .blank:
let person = Person()
modelContext.insert(person)
onPerson(person, .blank)
case .scan:
#if os(iOS)
showingScanSheet = true
#else
showFilePicker = true
#endif
case .contact:
showContactPicker = true
}
}

private func showPickedContact() {
guard let contact = pickedContact else { return }
pickedContact = nil
importedContact = contact
}

#if os(iOS)
private func processPendingScan() {
guard let result = pendingScan else { return }
pendingScan = nil
scanProcessingResult = MRZResultProcessor.process(result, context: .standalone, modelContext: modelContext)
}
#endif

private func deliverPendingPerson() {
guard let pending = pendingPerson else { return }
pendingPerson = nil
onPerson(pending.person, pending.method)
}
}
1 change: 1 addition & 0 deletions app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ struct FlightEditView: View {
Button(action: openPeoplePicker) {
Label("Edit Crew & Passengers", systemImage: "person.badge.plus")
}
.accessibilityIdentifier("editPeopleButton")
}
}

Expand Down
93 changes: 11 additions & 82 deletions app/flyfun-forms/flyfun-forms/Views/PeopleListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,9 @@ struct PeopleListView: View {
@State private var searchText = ""
@State private var sortByLastUsed = false
@State private var newPerson: Person?
@State private var showingScanSheet = false
@State private var scanProcessingResult: MRZProcessingResult?
@State private var navigateToPerson: Person?
@State private var showContactPicker = false
@State private var importedContact: ImportedContact?
@State private var addRequest: AddPersonMethod?
#if os(macOS)
@State private var showFilePicker = false
@State private var imageOCR = ImageOCRManager()
@State private var showingExporter = false
#endif

Expand Down Expand Up @@ -83,32 +78,7 @@ struct PeopleListView: View {
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
let person = Person()
modelContext.insert(person)
newPerson = person
} label: {
Label("Add Person", systemImage: "person.badge.plus")
}
.accessibilityIdentifier("addPersonButton")
#if os(iOS)
Button {
showingScanSheet = true
} label: {
Label("Scan Document", systemImage: "doc.text.viewfinder")
}
#else
Button {
showFilePicker = true
} label: {
Label("Scan Document", systemImage: "doc.text.viewfinder")
}
#endif
Button {
showContactPicker = true
} label: {
Label("Import from Contact", systemImage: "person.crop.rectangle")
}
AddPersonMenuItems(request: $addRequest)
Button {
showingImporter = true
} label: {
Expand Down Expand Up @@ -143,21 +113,10 @@ struct PeopleListView: View {
} message: {
Text(importResult?.message ?? "")
}
#if os(iOS)
.sheet(isPresented: $showContactPicker) {
ContactPickerSheet { contact in
importedContact = ImportedContact(from: contact)
}
}
#else
.sheet(isPresented: $showContactPicker) {
ContactSearchView { contact in
importedContact = ImportedContact(from: contact)
}
}
#endif
.sheet(item: $importedContact) { contact in
ContactResolveView(contact: contact) { person in
.addPersonFlows(request: $addRequest) { person, method in
if method == .blank {
newPerson = person
} else {
navigateToPerson = person
}
}
Expand All @@ -167,50 +126,20 @@ struct PeopleListView: View {
.navigationDestination(item: $newPerson) { person in
PersonEditView(person: person)
}
#if os(iOS)
.sheet(isPresented: $showingScanSheet) {
ScanDocumentSheet { result in
let processing = MRZResultProcessor.process(result, context: .standalone, modelContext: modelContext)
scanProcessingResult = processing
}
}
#else
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.pdf, .image],
allowsMultipleSelection: false
) { result in
if case .success(let urls) = result, let url = urls.first {
imageOCR.scan(url: url)
}
}
.onChange(of: imageOCR.status) { _, newStatus in
if newStatus == .success, let result = imageOCR.result {
let processing = MRZResultProcessor.process(result, context: .standalone, modelContext: modelContext)
scanProcessingResult = processing
} else if newStatus == .noMRZFound {
importResult = ImportResult(
title: String(localized: "No Document Found"),
message: String(localized: "No machine-readable zone (MRZ) was found in the file. Try a clearer image or PDF of the passport page.")
)
.onChange(of: newPerson) { left, _ in
// Backed out of Add Person without typing anything.
if let left, left.isBlank {
modelContext.delete(left)
}
}
#if os(macOS)
.fileExporter(
isPresented: $showingExporter,
document: CSVExportDocument(people: people),
contentType: .commaSeparatedText,
defaultFilename: "people.csv"
) { _ in }
#endif
.sheet(item: $scanProcessingResult) { processing in
MRZResultActionView(
processingResult: processing,
onDismiss: { scanProcessingResult = nil },
onPersonSelected: { person in
navigateToPerson = person
}
)
}
.navigationDestination(item: $navigateToPerson) { person in
PersonEditView(person: person)
}
Expand Down
Loading
Loading