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>
This commit is contained in:
342
Flights/Views/AirportSearchField.swift
Normal file
342
Flights/Views/AirportSearchField.swift
Normal file
@@ -0,0 +1,342 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user