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>
343 lines
12 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|