diff --git a/app/flyfun-forms/flyfun-forms/Localizable.xcstrings b/app/flyfun-forms/flyfun-forms/Localizable.xcstrings index 7a42b00..ca6ca16 100644 --- a/app/flyfun-forms/flyfun-forms/Localizable.xcstrings +++ b/app/flyfun-forms/flyfun-forms/Localizable.xcstrings @@ -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" : { diff --git a/app/flyfun-forms/flyfun-forms/Models/Person.swift b/app/flyfun-forms/flyfun-forms/Models/Person.swift index 5edc9db..6f78af0 100644 --- a/app/flyfun-forms/flyfun-forms/Models/Person.swift +++ b/app/flyfun-forms/flyfun-forms/Models/Person.swift @@ -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) diff --git a/app/flyfun-forms/flyfun-forms/Views/AddPersonMenu.swift b/app/flyfun-forms/flyfun-forms/Views/AddPersonMenu.swift new file mode 100644 index 0000000..570f0c7 --- /dev/null +++ b/app/flyfun-forms/flyfun-forms/Views/AddPersonMenu.swift @@ -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, + 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) + } +} diff --git a/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift b/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift index eb47911..b1db972 100644 --- a/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift +++ b/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift @@ -457,6 +457,7 @@ struct FlightEditView: View { Button(action: openPeoplePicker) { Label("Edit Crew & Passengers", systemImage: "person.badge.plus") } + .accessibilityIdentifier("editPeopleButton") } } diff --git a/app/flyfun-forms/flyfun-forms/Views/PeopleListView.swift b/app/flyfun-forms/flyfun-forms/Views/PeopleListView.swift index eb2f55f..92d2e9c 100644 --- a/app/flyfun-forms/flyfun-forms/Views/PeopleListView.swift +++ b/app/flyfun-forms/flyfun-forms/Views/PeopleListView.swift @@ -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 @@ -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: { @@ -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 } } @@ -167,34 +126,13 @@ 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), @@ -202,15 +140,6 @@ struct PeopleListView: View { 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) } diff --git a/app/flyfun-forms/flyfun-forms/Views/PeoplePickerView.swift b/app/flyfun-forms/flyfun-forms/Views/PeoplePickerView.swift index 76eaf11..8f93d06 100644 --- a/app/flyfun-forms/flyfun-forms/Views/PeoplePickerView.swift +++ b/app/flyfun-forms/flyfun-forms/Views/PeoplePickerView.swift @@ -2,20 +2,30 @@ import SwiftUI import SwiftData /// Multi-select people picker with search, last-used sorting, and co-traveler group suggestions. -/// Shows crew/passenger toggle for each selected person. +/// Shows crew/passenger toggle for each selected person. Someone not in the +/// app yet can be added from here (blank, scanned or from a contact) and goes +/// straight into the selection. struct PeoplePickerView: View { @Binding var selectedCrew: [Person] @Binding var selectedPassengers: [Person] @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext @Query(sort: \Person.lastName) private var allPeople: [Person] @State private var searchText = "" + @State private var addRequest: AddPersonMethod? + /// The person open in the editor pushed over the picker. + @State private var editingPerson: Person? + /// A person this picker created empty: taken away again if the editor + /// closes with nothing entered. + @State private var createdPersonID: PersistentIdentifier? var body: some View { NavigationStack { VStack(spacing: 0) { TextField("Search people...", text: $searchText) .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("peopleSearchField") .padding(.horizontal) .padding(.vertical, 8) #if os(iOS) @@ -25,6 +35,7 @@ struct PeoplePickerView: View { List { selectedSection matchingPeopleSection + addSearchedPersonSection groupSuggestionsSection } #if os(iOS) @@ -38,10 +49,33 @@ struct PeoplePickerView: View { .navigationBarTitleDisplayMode(.inline) #endif .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + AddPersonMenuItems(request: $addRequest, addPersonIdentifier: "pickerAddPersonButton") + } label: { + Label("Add", systemImage: "plus") + } + .accessibilityIdentifier("pickerAddPersonMenu") + } ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + .addPersonFlows(request: $addRequest) { person, method in + addNewPerson(person, method: method) + } + .navigationDestination(item: $editingPerson) { person in + PersonEditView(person: person) + } + .onChange(of: editingPerson) { left, _ in + discardIfBlank(left) + } + } + // On the stack, not its root: the root also disappears when the editor + // is pushed over it, while the person is still empty. + .onDisappear { + // Swiped away with the editor still open. + discardIfBlank(editingPerson) } #if os(macOS) .frame(minWidth: 500, minHeight: 500) @@ -83,6 +117,7 @@ struct PeoplePickerView: View { Button(role) { onToggle() } + .accessibilityIdentifier("selectedRole-\(person.lastName)") .font(.caption) .buttonStyle(.bordered) .controlSize(.mini) @@ -143,6 +178,34 @@ struct PeoplePickerView: View { } } + // MARK: - Add Searched Person + + /// The search found nobody: offer to create them under the name typed. + @ViewBuilder + private var addSearchedPersonSection: some View { + let name = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty && filteredPeople.isEmpty { + Section { + Button { + let person = Person() + (person.firstName, person.lastName) = Self.splitName(name) + modelContext.insert(person) + addNewPerson(person, method: .blank) + } label: { + Label("Add “\(name)” as new person", systemImage: "person.badge.plus") + } + .accessibilityIdentifier("addSearchedPersonButton") + } + } + } + + /// "Smith" is a last name; "Jane van Dijk" is Jane + van Dijk. + static func splitName(_ name: String) -> (first: String, last: String) { + let words = name.split(whereSeparator: \.isWhitespace) + guard words.count > 1 else { return ("", name) } + return (String(words[0]), words.dropFirst().joined(separator: " ")) + } + // MARK: - Group Suggestions @ViewBuilder @@ -190,6 +253,45 @@ struct PeoplePickerView: View { } } + /// Select a person the add flows ended with. A scan has already filled in + /// the name and document; a blank or contact person still needs them, so + /// the editor opens over the picker. + private func addNewPerson(_ person: Person, method: AddPersonMethod) { + addPerson(person) + searchText = "" + switch method { + case .blank: + createdPersonID = person.persistentModelID + editingPerson = person + case .contact: + editingPerson = person + case .scan: + break + } + } + + private func discardIfBlank(_ person: Person?) { + guard let person, person.persistentModelID == createdPersonID else { return } + createdPersonID = nil + Self.discardIfBlank(person, crew: &selectedCrew, passengers: &selectedPassengers, in: modelContext) + } + + /// Take a person this picker created off the selection and out of the + /// store if nothing was entered for them. Only for a person the picker + /// created: one picked through a scan match or contact merge was already + /// there, and stays whatever it holds. + @discardableResult + static func discardIfBlank( + _ person: Person, crew: inout [Person], passengers: inout [Person], in context: ModelContext + ) -> Bool { + guard person.isBlank else { return false } + let id = person.persistentModelID + crew.removeAll { $0.persistentModelID == id } + passengers.removeAll { $0.persistentModelID == id } + context.delete(person) + return true + } + private var filteredPeople: [Person] { let needle = searchText.lowercased() let people: [Person] diff --git a/app/flyfun-forms/flyfun-formsTests/AddPersonTests.swift b/app/flyfun-forms/flyfun-formsTests/AddPersonTests.swift new file mode 100644 index 0000000..e6f5851 --- /dev/null +++ b/app/flyfun-forms/flyfun-formsTests/AddPersonTests.swift @@ -0,0 +1,112 @@ +import Testing +import Foundation +import SwiftData +@testable import flyfun_forms + +/// Creates an in-memory ModelContainer for testing (no CloudKit, no persistence). +private func makeTestContainer() throws -> ModelContainer { + let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) + return try ModelContainer( + for: Person.self, TravelDocument.self, Aircraft.self, Flight.self, Trip.self, + configurations: config + ) +} + +@MainActor +private func peopleCount(in context: ModelContext) throws -> Int { + try context.fetchCount(FetchDescriptor()) +} + +@Suite("Adding a person") +@MainActor +struct AddPersonTests { + + // MARK: - Person.isBlank + + @Test("A new person is blank") + func newPersonIsBlank() { + #expect(Person().isBlank) + } + + @Test("Whitespace alone is still blank") + func whitespaceIsBlank() { + let person = Person(firstName: " ", lastName: "\n") + person.phone = " " + #expect(person.isBlank) + } + + @Test("Any entered detail makes a person not blank") + func anyDetailIsNotBlank() { + #expect(!Person(firstName: "Jane").isBlank) + #expect(!Person(lastName: "Zztest").isBlank) + let dated = Person() + dated.dateOfBirth = Date(timeIntervalSince1970: 0) + #expect(!dated.isBlank) + let reachable = Person() + reachable.email = "someone@example.com" + #expect(!reachable.isBlank) + } + + @Test("A person holding a document is not blank, even an empty one") + func documentIsNotBlank() throws { + let container = try makeTestContainer() + let context = container.mainContext + let person = Person() + context.insert(person) + let doc = TravelDocument() + doc.person = person + context.insert(doc) + #expect(!person.isBlank) + } + + // MARK: - Discarding + + @Test("A blank person is taken off the selection and deleted") + func blankPersonDiscarded() throws { + let container = try makeTestContainer() + let context = container.mainContext + let pilot = Person(firstName: "Test", lastName: "Pilot") + let blank = Person() + context.insert(pilot) + context.insert(blank) + var crew = [pilot] + var passengers = [blank] + + let discarded = PeoplePickerView.discardIfBlank(blank, crew: &crew, passengers: &passengers, in: context) + try context.save() + + #expect(discarded) + #expect(crew.map(\.lastName) == ["Pilot"]) + #expect(passengers.isEmpty) + #expect(try peopleCount(in: context) == 1) + } + + @Test("A person with a name is kept and stays selected") + func namedPersonKept() throws { + let container = try makeTestContainer() + let context = container.mainContext + let traveller = Person(firstName: "New", lastName: "Traveller") + context.insert(traveller) + var crew: [Person] = [] + var passengers = [traveller] + + let discarded = PeoplePickerView.discardIfBlank(traveller, crew: &crew, passengers: &passengers, in: context) + try context.save() + + #expect(!discarded) + #expect(passengers.count == 1) + #expect(try peopleCount(in: context) == 1) + } + + // MARK: - Name from search text + + @Test("One word is a last name") + func oneWordIsLastName() { + #expect(PeoplePickerView.splitName("Zztest") == ("", "Zztest")) + } + + @Test("The first word is the first name, the rest the last name") + func severalWords() { + #expect(PeoplePickerView.splitName("Jane van Zztest") == ("Jane", "van Zztest")) + } +} diff --git a/app/flyfun-forms/flyfun-formsUITests/flyfun_formsUITests.swift b/app/flyfun-forms/flyfun-formsUITests/flyfun_formsUITests.swift index 7a66bee..5c705ef 100644 --- a/app/flyfun-forms/flyfun-formsUITests/flyfun_formsUITests.swift +++ b/app/flyfun-forms/flyfun-formsUITests/flyfun_formsUITests.swift @@ -347,6 +347,75 @@ final class flyfun_formsUITests: XCTestCase { "New Person should be listed") } + /// Journey 7b: someone not in the app yet is added from the crew/passenger + /// picker and lands on the flight as a passenger, without leaving it. + @MainActor + func testAddPassengerFromPeoplePicker() throws { + let app = launchApp() + openPeoplePicker(app) + + element(app, "pickerAddPersonMenu").tap() + let add = element(app, "pickerAddPersonButton") + XCTAssertTrue(add.waitForExistence(timeout: Self.uiTimeout), "the picker's + should offer Add Person") + add.tap() + + let firstName = app.textFields["personFirstNameField"] + XCTAssertTrue(firstName.waitForExistence(timeout: Self.uiTimeout), "the person editor should open over the picker") + firstName.tap() + firstName.typeText("New") + let lastName = app.textFields["personLastNameField"] + lastName.tap() + lastName.typeText("Traveller") + backFromEditor(app, title: "New Traveller") + + let role = element(app, "selectedRole-Traveller") + XCTAssertTrue(role.waitForExistence(timeout: Self.uiTimeout), "the new person should be selected") + XCTAssertEqual(role.label, "Passenger", "a new person is a passenger") + + app.buttons["Done"].firstMatch.tap() + XCTAssertTrue(app.staticTexts["New Traveller"].waitForExistence(timeout: Self.uiTimeout), + "the new person should be listed among the flight's passengers") + } + + /// Journey 7c: backing out of Add Person without typing leaves nobody + /// behind, and a name the search didn't find can be added as it was typed. + @MainActor + func testPeoplePickerDiscardsBlankAndAddsSearchedName() throws { + let app = launchApp() + openPeoplePicker(app) + + element(app, "pickerAddPersonMenu").tap() + let add = element(app, "pickerAddPersonButton") + XCTAssertTrue(add.waitForExistence(timeout: Self.uiTimeout)) + add.tap() + XCTAssertTrue(app.textFields["personFirstNameField"].waitForExistence(timeout: Self.uiTimeout)) + backFromEditor(app, title: "New Person") + + let search = app.textFields["peopleSearchField"] + XCTAssertTrue(search.waitForExistence(timeout: Self.uiTimeout), "back in the picker") + XCTAssertFalse(element(app, "selectedRole-").exists, "an empty person should not stay selected") + + search.tap() + search.typeText("Jane Zztest") + let addSearched = element(app, "addSearchedPersonButton") + XCTAssertTrue(addSearched.waitForExistence(timeout: Self.uiTimeout), + "a search that finds nobody should offer to add them") + addSearched.tap() + let lastName = app.textFields["personLastNameField"] + XCTAssertTrue(lastName.waitForExistence(timeout: Self.uiTimeout)) + XCTAssertEqual(lastName.value as? String, "Zztest", "the searched name should be filled in") + backFromEditor(app, title: "Jane Zztest") + XCTAssertTrue(element(app, "selectedRole-Zztest").waitForExistence(timeout: Self.uiTimeout), + "the searched person should be selected") + + app.buttons["Done"].firstMatch.tap() + goBack(app) + openTab(app, "People") + XCTAssertTrue(element(app, "personRow-Zztest").waitForExistence(timeout: Self.uiTimeout), + "the searched person should be saved") + XCTAssertFalse(element(app, "personRow-").exists, "no empty person should be left in People") + } + /// Journey 8: return flight, next leg and duplicate each open the new leg /// with the route it should have, and each lands in the list. @MainActor @@ -450,6 +519,25 @@ final class flyfun_formsUITests: XCTestCase { result.tap() } + /// The outbound leg's crew/passenger picker, from its passengers section. + @MainActor + private func openPeoplePicker(_ app: XCUIApplication) { + openLeg(app, "flightRow-EGTF-LFRM") + focusSection(app, "passengers") + tapAction(app, "editPeopleButton") + XCTAssertTrue(element(app, "pickerAddPersonMenu").waitForExistence(timeout: Self.uiTimeout), + "the people picker should open") + } + + /// Back from a person editor pushed inside a sheet. The flight's own bar + /// is still in the tree underneath, so go by the editor's title. + @MainActor + private func backFromEditor(_ app: XCUIApplication, title: String) { + let back = app.navigationBars[title].buttons.element(boundBy: 0) + XCTAssertTrue(back.waitForExistence(timeout: Self.uiTimeout), "the editor titled \(title) should have a way back") + back.tap() + } + @MainActor private func goBack(_ app: XCUIApplication) { let back = app.navigationBars.buttons.element(boundBy: 0) diff --git a/designs/ios-app.md b/designs/ios-app.md index 778a14f..09ef374 100644 --- a/designs/ios-app.md +++ b/designs/ios-app.md @@ -20,7 +20,9 @@ app/flyfun-forms/flyfun-forms/ │ ├── Trip.swift # @Model: multi-leg trip container ├── Views/ │ ├── LoginView.swift # Google/Apple OAuth sign-in -│ ├── PeopleListView.swift # CRUD for people + contact import button +│ ├── PeopleListView.swift # CRUD for people, CSV import/export +│ ├── AddPersonMenu.swift # Add Person / Scan / Contact flows, shared by People and the picker +│ ├── PeoplePickerView.swift # Crew/passenger multi-select picker, can add new people │ ├── PersonEditView.swift # Person details + document list │ ├── AircraftListView.swift │ ├── AircraftEditView.swift @@ -168,6 +170,15 @@ Parsing is `RZFlight.ICAOFlightPlanParser` (do not reimplement it locally: forms - **Create new:** creates a fresh `Person` with phone, email, address, DOB from the contact - **Merge into existing:** either "Fill Missing Only" (preserves existing fields) or "Override All" mode +### Adding People + +`AddPersonMenuItems` + `.addPersonFlows(request:onPerson:)` (`AddPersonMenu.swift`) are the one-at-a-time ways to create a person: **Add Person** (blank), **Scan Document** (MRZ via camera on iOS, image/PDF on macOS, `MRZResultProcessor` `.standalone`) and **Import from Contact**. The flow ends with a single callback carrying the person, new or an existing one the scan matched / the contact was merged into. The People tab opens that person; CSV import stays there only. + +`PeoplePickerView` (crew/passenger picker, used by both `FlightEditView` and `NewFlightFlow`) hangs the same menu off its (+). The person goes straight into the selection (passenger unless usual crew), and Add Person / Contact push `PersonEditView` inside the picker's stack; a scan has already filled the name and document, so it doesn't. A search that finds nobody offers "Add “name” as new person". + +- **Sheet hand-off:** the contact picker, scan sheet, `ContactResolveView` and `MRZResultActionView` all call back *before* they dismiss. The flows hold the result and present the next step, or report the person, from the sheet's `onDismiss` — presenting or pushing while a sheet is still leaving is silently dropped, which matters inside the picker's own sheet. +- **Blank people:** `PersonEditView` has no Cancel, so Add Person inserts the `Person` up front. When the editor closes and `Person.isBlank` (no name, date of birth, contact details, place of birth or document), the People tab deletes it and the picker takes it off the selection and deletes it — the picker only for a person it created, never one reached by a match or merge. The picker's swipe-away check sits on its `NavigationStack`, not the root list: the root also disappears when the editor is pushed. + ### Form Export Two export paths per form: **Share** (generic share sheet) and **Email** (pre-populated mail composer):