Files
Flights/Flights/Views/AirportSearchField.swift
Trey t 3790792040 Initial commit: Flights iOS app
Flight search app built on FlightConnections.com API data.
Features: airport search with autocomplete, browse by country/state/map,
flight schedules by route and date, multi-airline support with per-airline
schedule loading. Includes 4,561-airport GPS database for map browsing.
Adaptive light/dark mode UI inspired by Flighty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:01:07 -05:00

343 lines
12 KiB
Swift

import SwiftUI
struct AirportSearchField: View {
let label: String
@Binding var searchText: String
@Binding var selectedAirport: Airport?
let suggestions: [Airport]
let countrySuggestions: [Country]
let regionResult: (regionName: String, airports: [MapAirport])?
let isSearching: Bool
let service: FlightService
let database: AirportDatabase
let onTextChanged: () -> Void
let onSelect: (Airport) -> Void
let onClear: () -> Void
@State private var activeSheet: AirportSheet?
private enum AirportSheet: Identifiable {
case browser
case country(Country)
case region(name: String, airports: [MapAirport])
var id: String {
switch self {
case .browser: return "browser"
case .country(let c): return "country-\(c.id)"
case .region(let name, _): return "region-\(name)"
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// MARK: - Text Field Row
HStack {
TextField(label, text: $searchText)
.foregroundStyle(.primary)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
.onChange(of: searchText) {
onTextChanged()
}
if selectedAirport != nil {
Button {
onClear()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
} else if isSearching {
ProgressView()
.controlSize(.small)
}
Button {
activeSheet = .browser
} label: {
Image(systemName: "globe")
.foregroundStyle(FlightTheme.accent)
}
.buttonStyle(.plain)
}
// MARK: - Suggestions
let hasResults = !suggestions.isEmpty || !countrySuggestions.isEmpty || regionResult != nil
if selectedAirport == nil && hasResults {
Divider()
.padding(.vertical, 6)
ForEach(suggestions) { airport in
Button {
onSelect(airport)
} label: {
HStack(spacing: 6) {
Image(systemName: "airplane")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(airport.iata) - \(airport.name)")
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 8)
if airport.id != suggestions.last?.id {
Divider()
}
}
ForEach(countrySuggestions) { country in
if !suggestions.isEmpty || country.id != countrySuggestions.first?.id {
Divider()
}
Button {
activeSheet = .country(country)
} label: {
HStack(spacing: 6) {
Image(systemName: "flag")
.font(.caption)
.foregroundStyle(.orange)
Text(country.name)
.foregroundStyle(.primary)
Text("(\(country.id))")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 8)
}
if let region = regionResult {
if !suggestions.isEmpty || !countrySuggestions.isEmpty {
Divider()
}
Button {
activeSheet = .region(name: region.regionName, airports: region.airports)
} label: {
HStack(spacing: 6) {
Image(systemName: "map")
.font(.caption)
.foregroundStyle(FlightTheme.onTime)
Text(region.regionName)
.foregroundStyle(.primary)
Text("(\(region.airports.count) airports)")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 8)
}
}
}
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .browser:
AirportBrowserSheet(service: service, database: database) { airport in
onSelect(airport)
activeSheet = nil
}
case .country(let country):
CountryAirportPickerSheet(
country: country,
service: service,
onSelect: { airport in
onSelect(airport)
activeSheet = nil
}
)
case .region(let name, let airports):
RegionAirportPickerSheet(
regionName: name,
airports: airports,
service: service,
onSelect: { airport in
onSelect(airport)
activeSheet = nil
}
)
}
}
}
}
// MARK: - Country Airport Picker Sheet
/// Sheet that shows airports in a specific country for direct selection
private struct CountryAirportPickerSheet: View {
let country: Country
let service: FlightService
let onSelect: (Airport) -> Void
@Environment(\.dismiss) private var dismiss
@State private var airports: [BrowseAirport] = []
@State private var isLoading = true
@State private var error: String?
@State private var search = ""
@State private var selectingIATA: String?
var filteredAirports: [BrowseAirport] {
guard !search.isEmpty else { return airports }
return airports.filter {
$0.city.localizedCaseInsensitiveContains(search) ||
$0.iata.localizedCaseInsensitiveContains(search)
}
}
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading airports...")
} else if airports.isEmpty {
ContentUnavailableView(
"No Airports",
systemImage: "airplane.slash",
description: Text("No airports found in \(country.name).")
)
} else {
List(filteredAirports) { airport in
Button {
selectAirport(airport)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(airport.city)
.font(.headline)
.foregroundStyle(.primary)
Text(airport.iata)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectingIATA == airport.iata {
ProgressView()
.controlSize(.small)
}
}
}
.disabled(selectingIATA != nil)
}
.searchable(text: $search, prompt: "Search airports")
}
}
.navigationTitle("Airports in \(country.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.task {
do {
airports = try await service.fetchAirports(country: country)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
}
private func selectAirport(_ browseAirport: BrowseAirport) {
selectingIATA = browseAirport.iata
Task {
do {
let results = try await service.searchAirports(term: browseAirport.iata)
if let match = results.first(where: { $0.iata == browseAirport.iata }) {
onSelect(match)
} else {
onSelect(Airport(id: "", iata: browseAirport.iata, name: browseAirport.city))
}
} catch {
selectingIATA = nil
}
}
}
}
// MARK: - Region Airport Picker Sheet
/// Sheet that shows airports in a region/state for direct selection
private struct RegionAirportPickerSheet: View {
let regionName: String
let airports: [MapAirport]
let service: FlightService
let onSelect: (Airport) -> Void
@Environment(\.dismiss) private var dismiss
@State private var search = ""
@State private var selectingIATA: String?
var filteredAirports: [MapAirport] {
guard !search.isEmpty else { return airports }
return airports.filter {
$0.name.localizedCaseInsensitiveContains(search) ||
$0.iata.localizedCaseInsensitiveContains(search)
}
}
var body: some View {
NavigationStack {
List(filteredAirports) { airport in
Button {
selectAirport(airport)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(airport.name)
.font(.headline)
.foregroundStyle(.primary)
Text(airport.iata)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectingIATA == airport.iata {
ProgressView()
.controlSize(.small)
}
}
}
.disabled(selectingIATA != nil)
}
.searchable(text: $search, prompt: "Search airports")
.navigationTitle("Airports in \(regionName)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
private func selectAirport(_ mapAirport: MapAirport) {
selectingIATA = mapAirport.iata
Task {
do {
let results = try await service.searchAirports(term: mapAirport.iata)
if let match = results.first(where: { $0.iata == mapAirport.iata }) {
onSelect(match)
} else {
// API doesn't know this airport use bundled data
onSelect(Airport(id: "", iata: mapAirport.iata, name: mapAirport.name))
}
} catch {
selectingIATA = nil
}
}
}
}