diff --git a/app/flyfun-forms/flyfun-forms/Localizable.xcstrings b/app/flyfun-forms/flyfun-forms/Localizable.xcstrings index 97d2aec..f12410c 100644 --- a/app/flyfun-forms/flyfun-forms/Localizable.xcstrings +++ b/app/flyfun-forms/flyfun-forms/Localizable.xcstrings @@ -457,6 +457,28 @@ } } }, + "All" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Alle" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Todo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tout" + } + } + } + }, "Arrival Date" : { "localizations" : { "de" : { diff --git a/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift b/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift index 53b2f07..948b577 100644 --- a/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift +++ b/app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift @@ -35,6 +35,8 @@ struct FlightEditView: View { @State private var flightDetailsExpanded = true @State private var crewExpanded = true @State private var passengersExpanded = true + /// Which section the compact nav bar is showing. `nil` shows all of them. + @State private var selectedSection: String? /// Formats the API's `departure_date` / `arrival_date`. /// @@ -143,15 +145,96 @@ struct FlightEditView: View { // MARK: - Layouts private var compactLayout: some View { - Form { - routeSection - scheduleSection - flightDetailsSection - peopleButton - crewSection - passengersSection - formSections - actionsSection + VStack(spacing: 0) { + FlightSectionNavBar( + sections: navSections, + selected: effectiveSelection ?? FlightSectionNavBar.allSectionID, + onSelect: select + ) + Form { + if shows("route") { routeSection } + if shows("schedule") { scheduleSection } + if shows("details") { flightDetailsSection } + if shows("crew") || shows("passengers") { peopleButton } + if shows("crew") { crewSection } + if shows("passengers") { passengersSection } + formSections + if shows("actions") { actionsSection } + } + } + } + + // MARK: - Section selector (compact only) + + /// Pills in document order. "All" is not here — the bar pins it. Form + /// sections appear only when `formSections` will actually render them, so a + /// pill can never select a section that turns out to be empty. + private var navSections: [FlightSection] { + var sections: [FlightSection] = [ + FlightSection("route", String(localized: "Route")), + FlightSection("schedule", String(localized: "Schedule")), + FlightSection("details", String(localized: "Details")), + FlightSection("crew", String(localized: "Crew")), + FlightSection("passengers", String(localized: "Passengers")), + ] + sections += formIDs.map { FlightSection($0.id, $0.title) } + sections.append(FlightSection("actions", String(localized: "Actions"))) + return sections + } + + /// The form sections `formSections` will render, in the same order. + private var formIDs: [(id: String, title: String)] { + var ids: [(id: String, title: String)] = [] + if !flight.destinationICAO.isEmpty, + hasForms(airport: flight.destinationICAO, direction: "arrival") { + ids.append((id: formSectionID(direction: "arrival"), + title: "\(flight.destinationICAO) arr")) + } + if !flight.originICAO.isEmpty, + hasForms(airport: flight.originICAO, direction: "departure") { + ids.append((id: formSectionID(direction: "departure"), + title: "\(flight.originICAO) dep")) + } + return ids + } + + private func formSectionID(direction: String) -> String { "form-\(direction)" } + + /// Falls back to showing everything when the selection names a section this + /// flight does not have — `switchToFlight` keeps the same view instance, so + /// the previous flight's arrival forms can still be selected. + private var effectiveSelection: String? { + guard let selectedSection, + navSections.contains(where: { $0.id == selectedSection }) + else { return nil } + return selectedSection + } + + /// Wide layout has no bar, so it always shows every section. + private func shows(_ id: String) -> Bool { + guard sizeClass != .regular, let selection = effectiveSelection else { return true } + return selection == id + } + + private func select(_ id: String) { + // Tapping the section you are already in is the second way back to the + // full list, so the pinned "All" is never the only route. + let backToAll = id == FlightSectionNavBar.allSectionID || id == effectiveSelection + // Showing a lone collapsed DisclosureGroup would be a title and nothing + // else, so a section opens when it is picked. + if !backToAll { expandSection(id) } + withAnimation(.easeInOut(duration: 0.2)) { + selectedSection = backToAll ? nil : id + } + } + + private func expandSection(_ id: String) { + switch id { + case "schedule": scheduleExpanded = true + case "details": flightDetailsExpanded = true + case "crew": crewExpanded = true + case "passengers": passengersExpanded = true + default: break } } @@ -348,10 +431,10 @@ struct FlightEditView: View { @ViewBuilder private var formSections: some View { - if !flight.destinationICAO.isEmpty { + if !flight.destinationICAO.isEmpty, shows(formSectionID(direction: "arrival")) { formSection(airport: flight.destinationICAO, direction: "arrival") } - if !flight.originICAO.isEmpty { + if !flight.originICAO.isEmpty, shows(formSectionID(direction: "departure")) { formSection(airport: flight.originICAO, direction: "departure") } } @@ -414,13 +497,22 @@ struct FlightEditView: View { // MARK: - Form Sections + /// Whether `formSection` will render anything for this side. `formIDs` + /// reads the same predicate, so the bar can never offer a pill for a + /// section that turns out to be empty. + private func hasForms(airport: String, direction: String) -> Bool { + let allForms = formDetails[airport] ?? [] + return allForms.contains { !$0.isWebForm } + || allForms.contains { $0.isWebForm && ($0.direction ?? direction) == direction } + } + @ViewBuilder private func formSection(airport: String, direction: String) -> some View { let allForms = formDetails[airport] ?? [] let forms = allForms.filter { !$0.isWebForm } // Official web forms (book-out, PPR…) only on the side they cover let webForms = allForms.filter { $0.isWebForm && ($0.direction ?? direction) == direction } - if forms.first != nil || !webForms.isEmpty { + if hasForms(airport: airport, direction: direction) { Section("\(airport) — \(direction)") { if let primary = forms.first { formRow(airport: airport, formInfo: primary) diff --git a/app/flyfun-forms/flyfun-forms/Views/FlightSectionNav.swift b/app/flyfun-forms/flyfun-forms/Views/FlightSectionNav.swift new file mode 100644 index 0000000..e4c6677 --- /dev/null +++ b/app/flyfun-forms/flyfun-forms/Views/FlightSectionNav.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// One entry in the flight-edit section selector. +struct FlightSection: Identifiable, Equatable { + let id: String + let title: String + + init(_ id: String, _ title: String) { + self.id = id + self.title = title + } +} + +/// Horizontally-scrollable pill bar that picks which section of a long `Form` +/// is on screen. Compact width only — the wide layout already splits the flight +/// across two columns. +/// +/// This is the flyfun-weather briefing rail's *focus mode*, not its scroll-spy. +/// The scroll-spy could not come across: it rests on `.scrollTargetLayout()`, +/// `ScrollPosition` and `onScrollTargetVisibilityChange`, which only exist for +/// `ScrollView`, and the flight editor is a `List`-backed `Form`. Working +/// around that meant injecting anchor rows (a `List` row cannot be zero-height, +/// so each one drew a stray separator), measuring geometry inside row hosts +/// (a `.named(_:)` space declared on the `Form` does not resolve there) and +/// `scrollTo` calls that never landed. +/// +/// Selecting instead of scrolling removes the whole class of problem: the pill +/// *is* the state, so there is nothing to keep in sync and nothing to measure. +struct FlightSectionNavBar: View { + /// Shows every section, and the default. + static let allSectionID = "all" + + /// Section pills, in document order. "All" is not among them — it is + /// pinned, see below. + let sections: [FlightSection] + let selected: String + let onSelect: (String) -> Void + + /// Keeps the chosen pill in view when the selection changes from elsewhere. + @State private var pillPosition = ScrollPosition(idType: String.self) + + var body: some View { + HStack(spacing: 8) { + // "All" sits OUTSIDE the scroller. Focusing a section late in the + // list used to scroll the bar with it, leaving the way back several + // swipes to the left; pinned, it is always one tap away. + pill(FlightSection(Self.allSectionID, String(localized: "All"))) + + Divider().frame(height: 18) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(sections) { section in + pill(section) + } + } + .padding(.trailing, 16) + .scrollTargetLayout() + } + .scrollPosition($pillPosition) + .onChange(of: selected) { _, newValue in + withAnimation(.easeInOut(duration: 0.2)) { + pillPosition.scrollTo(id: newValue, anchor: .center) + } + } + } + .padding(.leading, 16) + .padding(.vertical, 6) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Rectangle().fill(Color.secondary.opacity(0.3)).frame(height: 0.5) + } + .accessibilityIdentifier("flightSectionNavBar") + } + + @ViewBuilder + private func pill(_ section: FlightSection) -> some View { + let isSelected = section.id == selected + Button { onSelect(section.id) } label: { + Text(section.title) + .font(.caption.weight(isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + isSelected ? Color.accentColor.opacity(0.14) : Color.clear, + in: Capsule() + ) + .overlay( + Capsule().stroke(Color.secondary.opacity(0.3), lineWidth: isSelected ? 0 : 0.5) + ) + } + .buttonStyle(.plain) + .id(section.id) + .accessibilityIdentifier("flightSectionPill_\(section.id)") + } +} diff --git a/designs/ios-app.md b/designs/ios-app.md index d717a28..b22dbcf 100644 --- a/designs/ios-app.md +++ b/designs/ios-app.md @@ -26,6 +26,7 @@ app/flyfun-forms/flyfun-forms/ │ ├── AircraftEditView.swift │ ├── FlightsListView.swift │ ├── FlightEditView.swift # Flight details + form generation via share/email +│ ├── FlightSectionNav.swift # Compact-width pill bar selecting one FlightEditView section │ ├── WebFormView.swift # Official web form (book-out, PPR…) in a web view, prefilled │ ├── NewFlightFlow.swift # Two-step new flight creation (route → people) │ ├── FlightDateTimeField.swift # Date/time/timezone entry for one end of a flight @@ -241,6 +242,7 @@ The `/archive` skill (`.claude/skills/archive/SKILL.md`) runs the pre-flight che - Localized email text with language preference (local/English/both): **complete** - NOTAM notification display in route section: **complete** - Collapsible flight detail sections (schedule, details, crew, passengers): **complete** +- Compact section selector bar on the flight editor (focus one section, "All" to return): **complete** - Next Leg / Return Flight / Duplicate with shared property copying: **complete** - Past/upcoming flight split with collapsible past section: **complete** - Searchable responsible person picker with contact auto-fill: **complete**