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
22 changes: 22 additions & 0 deletions app/flyfun-forms/flyfun-forms/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
116 changes: 104 additions & 12 deletions app/flyfun-forms/flyfun-forms/Views/FlightEditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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")
}
}
Expand Down Expand Up @@ -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)
Expand Down
97 changes: 97 additions & 0 deletions app/flyfun-forms/flyfun-forms/Views/FlightSectionNav.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
2 changes: 2 additions & 0 deletions designs/ios-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**
Expand Down
Loading