From 5958c8a70c2443c22cbc5e2fc3b7219d1eff8048 Mon Sep 17 00:00:00 2001 From: brice Date: Sat, 26 Sep 2026 10:47:20 +0200 Subject: [PATCH] feat(ios): add a new person straight from the crew/passenger picker Someone not in the app yet meant leaving the flight, creating them under People, and coming back to pick them. The picker now has a (+) with the same Add Person, Scan Document and Import from Contact as the People tab; the person it ends with, new or matched to someone already there, goes straight into the selection as a passenger (crew if they are usual crew). Add Person and a contact open the editor inside the picker, since both still need a document; a scan has filled that in, so it doesn't. A search that finds nobody offers to add them under the name typed. The three flows move out of PeopleListView into AddPersonMenu, so the People tab and the picker share one copy, including the macOS file-scan path. They now hand over a person, or present the next sheet, only once the sheet before has dismissed: every one of those sheets calls back first, and a push or presentation made while a sheet is still leaving is dropped. Backing out of Add Person without typing anything left an empty person in People, and in the picker would have put them on the flight. Person.isBlank now lets both take such a person away again; the picker only ever removes one it created itself. Closes #32 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../flyfun-forms/Localizable.xcstrings | 23 +++ .../flyfun-forms/Models/Person.swift | 12 ++ .../flyfun-forms/Views/AddPersonMenu.swift | 178 ++++++++++++++++++ .../flyfun-forms/Views/FlightEditView.swift | 1 + .../flyfun-forms/Views/PeopleListView.swift | 93 ++------- .../flyfun-forms/Views/PeoplePickerView.swift | 104 +++++++++- .../flyfun-formsTests/AddPersonTests.swift | 112 +++++++++++ .../flyfun_formsUITests.swift | 88 +++++++++ designs/ios-app.md | 13 +- 9 files changed, 540 insertions(+), 84 deletions(-) create mode 100644 app/flyfun-forms/flyfun-forms/Views/AddPersonMenu.swift create mode 100644 app/flyfun-forms/flyfun-formsTests/AddPersonTests.swift 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):