Files
Flights/Flights/Services/FlightService.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

508 lines
18 KiB
Swift

import Foundation
actor FlightService {
// MARK: - Configuration
private let session: URLSession
private let baseURL = "https://www.flightconnections.com"
private static let userAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
private var cachedCountries: [Country]?
private var cachedAirportsByCountry: [String: [BrowseAirport]] = [:]
init(session: URLSession = .shared) {
self.session = session
}
// MARK: - Public API
/// Search airports by term (autocomplete).
func searchAirports(term: String) async throws -> [Airport] {
let results = try await searchAll(term: term)
return results.airports
}
/// Search airports, countries, and continents by term.
func searchAll(term: String) async throws -> AutocompleteResults {
guard let encoded = term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(baseURL)/autocomplete_location.php?lang=en&term=\(encoded)")
else { throw FlightServiceError.invalidURL }
let request = makeRequest(url: url)
let data = try await perform(request)
let json = try parseJSON(data)
guard let dict = json as? [String: Any] else {
return AutocompleteResults(airports: [], countries: [])
}
let airportEntries = dict["airports"] as? [[String: Any]] ?? []
let airports: [Airport] = airportEntries.compactMap { entry in
guard let value = entry["value"] as? String,
let id = entry["id"] as? String
else { return nil }
let parts = value.split(separator: " - ", maxSplits: 1)
guard parts.count == 2 else { return nil }
let iata = String(parts[0]).trimmingCharacters(in: .whitespaces)
let name = String(parts[1]).trimmingCharacters(in: .whitespaces)
return Airport(id: id, iata: iata, name: name)
}
let countryEntries = dict["countries"] as? [[String: Any]] ?? []
let countries: [Country] = countryEntries.compactMap { entry in
guard let name = entry["name"] as? String,
let code = entry["code"] as? String
else { return nil }
let slug = name.lowercased().replacingOccurrences(of: " ", with: "-")
return Country(id: code, name: name, slug: slug)
}
return AutocompleteResults(airports: airports, countries: countries)
}
/// Fetch all nonstop destinations from a given airport.
func destinations(for airportId: String) async throws -> [Route] {
guard let url = URL(string: "\(baseURL)/rt\(airportId).json") else {
throw FlightServiceError.invalidURL
}
let request = makeRequest(url: url)
let data = try await perform(request)
let json = try parseJSON(data)
guard let dict = json as? [String: Any],
let pts = dict["pts"] as? [Int],
let crd = dict["crd"] as? [Double],
let mths = dict["mths"] as? [Int],
let clss = dict["clss"] as? [Int],
let lst = dict["lst"] as? [[Any]]
else { throw FlightServiceError.invalidResponse }
// pts[0] is origin; lst[i] maps to pts[i+1]
var routes: [Route] = []
for (i, item) in lst.enumerated() {
guard item.count >= 7,
let iata = item[0] as? String,
let city = item[1] as? String,
let countryCode = item[2] as? String,
let country = item[3] as? String
else { continue }
let durationStr = item[4] as? String ?? "\(item[4])"
let distanceStr = item[5] as? String ?? "\(item[5])"
let stopIndicator = item[6] as? String ?? "\(item[6])"
let duration = Int(durationStr) ?? 0
let distance = Int(distanceStr) ?? 0
let ptsIndex = i + 1
let destId = ptsIndex < pts.count ? String(pts[ptsIndex]) : ""
let monthBitmask = ptsIndex < mths.count ? mths[ptsIndex] : 0
let cabinBitmask = i < clss.count ? clss[i] : 0
let crdIndex = 2 * (i + 1)
let lat = crdIndex < crd.count ? crd[crdIndex] : 0
let lng = (crdIndex + 1) < crd.count ? crd[crdIndex + 1] : 0
let airport = Airport(
id: destId,
iata: iata,
name: city,
countryCode: countryCode,
country: country
)
routes.append(Route(
destinationAirport: airport,
durationMinutes: duration,
distanceMiles: distance,
monthsBitmask: monthBitmask,
cabinClasses: .from(bitmask: cabinBitmask),
latitude: lat,
longitude: lng
))
}
return routes
}
/// Fetch airlines operating a specific route.
func airlines(from depId: String, to desId: String) async throws -> [RouteAirline] {
guard let url = URL(string: "\(baseURL)/rt\(depId)_\(desId).json") else {
throw FlightServiceError.invalidURL
}
let request = makeRequest(url: url)
let data = try await perform(request)
let json = try parseJSON(data)
guard let dict = json as? [String: Any],
let dataArray = dict["data"] as? [[String: Any]]
else { throw FlightServiceError.invalidResponse }
return dataArray.compactMap { entry in
guard let routeArray = entry["route"] as? [Any],
routeArray.count >= 5,
let dist = entry["dist"] as? Int,
let dur = entry["dur"] as? Int,
let mths = entry["mths"] as? Int
else { return nil }
let airlineIdRaw = routeArray[0]
let airlineId: String
if let intId = airlineIdRaw as? Int {
airlineId = String(intId)
} else if let strId = airlineIdRaw as? String {
airlineId = strId
} else {
return nil
}
guard let name = routeArray[2] as? String,
let logoFilename = routeArray[3] as? String,
let iata = routeArray[4] as? String
else { return nil }
let airline = Airline(
id: airlineId,
name: name,
iata: iata,
logoFilename: logoFilename
)
return RouteAirline(
airline: airline,
distanceMiles: dist,
durationMinutes: dur,
monthsBitmask: mths
)
}
}
/// Fetch flight schedules for a specific airline on a route.
func schedules(
dep depId: String,
des desId: String,
airlineId: String,
airline: Airline
) async throws -> [FlightSchedule] {
guard let url = URL(string: "\(baseURL)/validity.php") else {
throw FlightServiceError.invalidURL
}
let calendar = Calendar.current
let currentYear = calendar.component(.year, from: Date())
let nextYear = currentYear + 1
var request = makeRequest(url: url)
request.httpMethod = "POST"
request.setValue(
"application/x-www-form-urlencoded; charset=UTF-8",
forHTTPHeaderField: "Content-Type"
)
let body = "dep=\(depId)&des=\(desId)&id=\(airlineId)&startDate=\(currentYear)&endDate=\(nextYear)&lang=en"
request.httpBody = body.data(using: .utf8)
let data = try await perform(request)
let json = try parseJSON(data)
guard let dict = json as? [String: Any],
let flights = dict["flights"] as? [[String: Any]]
else { return [] }
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(identifier: "UTC")
let dayKeys: [(String, Int)] = [
("su", 1), ("mo", 2), ("tu", 3), ("we", 4),
("th", 5), ("fr", 6), ("sa", 7)
]
return flights.compactMap { flight in
guard let rawFlightNumber = flight["flightnumber"] as? String,
let aircraft = flight["aircraft"] as? String,
let deptime = flight["deptime"] as? String,
let destime = flight["destime"] as? String,
let dateFromStr = flight["datefrom"] as? String,
let dateToStr = flight["dateto"] as? String,
let dateFrom = dateFormatter.date(from: dateFromStr),
let dateTo = dateFormatter.date(from: dateToStr)
else { return nil }
let flightNumber = rawFlightNumber
.split(separator: " ")
.joined(separator: " ")
.trimmingCharacters(in: .whitespaces)
let acId: String
if let idVal = flight["ac_id"] {
acId = "\(idVal)"
} else {
acId = ""
}
var days = Set<Int>()
for (key, weekday) in dayKeys {
if let val = flight[key] as? String, val == "1" {
days.insert(weekday)
}
}
let classesStr = flight["classes"] as? String ?? "0000"
let cabinClasses = CabinClass.from(classesString: classesStr)
// Strip seconds from time strings: "16:45:00" -> "16:45"
let depTimeFormatted = formatTime(deptime)
let arrTimeFormatted = formatTime(destime)
return FlightSchedule(
airline: airline,
flightNumber: flightNumber,
aircraft: aircraft,
aircraftId: acId,
departureTime: depTimeFormatted,
arrivalTime: arrTimeFormatted,
dateFrom: dateFrom,
dateTo: dateTo,
daysOfWeek: days,
cabinClasses: cabinClasses
)
}
}
/// Fetch all flight schedules for a route across all airlines.
/// Reports progress via the `onProgress` closure with (completed, total) counts.
func allSchedules(
dep depId: String,
des desId: String,
onProgress: @Sendable @escaping (Int, Int) -> Void
) async throws -> [FlightSchedule] {
let routeAirlines = try await airlines(from: depId, to: desId)
let total = routeAirlines.count
if total == 0 { return [] }
let completed = Counter()
let results: [FlightSchedule] = try await withThrowingTaskGroup(of: [FlightSchedule].self) { group in
var allSchedules: [FlightSchedule] = []
var launched = 0
var index = 0
// Launch initial batch (up to 3)
while index < routeAirlines.count, launched < 3 {
let ra = routeAirlines[index]
group.addTask { [self] in
try await self.schedules(
dep: depId,
des: desId,
airlineId: ra.airline.id,
airline: ra.airline
)
}
launched += 1
index += 1
}
// As each completes, launch next
for try await flights in group {
allSchedules.append(contentsOf: flights)
let current = await completed.increment()
onProgress(current, total)
if index < routeAirlines.count {
let ra = routeAirlines[index]
group.addTask { [self] in
try await self.schedules(
dep: depId,
des: desId,
airlineId: ra.airline.id,
airline: ra.airline
)
}
index += 1
}
}
return allSchedules
}
return results.sorted { $0.departureTime < $1.departureTime }
}
// MARK: - Browse API
/// Fetch all countries that have airports.
func fetchCountries() async throws -> [Country] {
if let cached = cachedCountries { return cached }
guard let url = URL(string: "\(baseURL)/airports-by-country") else {
throw FlightServiceError.invalidURL
}
let request = makeRequest(url: url)
let data = try await perform(request)
guard let html = String(data: data, encoding: .utf8) else {
throw FlightServiceError.invalidResponse
}
// Pattern: href="/airports-in-{slug}-{code}"
// Extract country name from slug (convert hyphens to spaces, title case)
let pattern = try NSRegularExpression(
pattern: #"href="/airports-in-([a-z-]+)-([a-z]{2})""#
)
let matches = pattern.matches(in: html, range: NSRange(html.startIndex..., in: html))
var seen = Set<String>()
var countries: [Country] = []
for match in matches {
guard let slugRange = Range(match.range(at: 1), in: html),
let codeRange = Range(match.range(at: 2), in: html) else { continue }
let slug = String(html[slugRange])
let code = String(html[codeRange]).uppercased()
guard !seen.contains(code) else { continue }
seen.insert(code)
let name = slug.split(separator: "-")
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
countries.append(Country(id: code, name: name, slug: slug))
}
countries.sort { $0.name.localizedCompare($1.name) == .orderedAscending }
cachedCountries = countries
return countries
}
/// Fetch all airports in a country.
func fetchAirports(country: Country) async throws -> [BrowseAirport] {
if let cached = cachedAirportsByCountry[country.id] { return cached }
guard let url = URL(string: "\(baseURL)/airports-in-\(country.slug)-\(country.id.lowercased())") else {
throw FlightServiceError.invalidURL
}
let request = makeRequest(url: url)
let data = try await perform(request)
guard let html = String(data: data, encoding: .utf8) else {
throw FlightServiceError.invalidResponse
}
// Pattern: title="Direct flights from {City} ({IATA})"
// or: title="Flights from {City} ({IATA})"
let pattern = try NSRegularExpression(
pattern: #"title="(?:Direct )?[Ff]lights from ([^(]+)\(([A-Z]{3})\)""#
)
let matches = pattern.matches(in: html, range: NSRange(html.startIndex..., in: html))
var seen = Set<String>()
var airports: [BrowseAirport] = []
for match in matches {
guard let cityRange = Range(match.range(at: 1), in: html),
let iataRange = Range(match.range(at: 2), in: html) else { continue }
let city = String(html[cityRange]).trimmingCharacters(in: .whitespaces)
let iata = String(html[iataRange])
guard !seen.contains(iata) else { continue }
seen.insert(iata)
airports.append(BrowseAirport(id: iata, iata: iata, city: city))
}
airports.sort { $0.city.localizedCompare($1.city) == .orderedAscending }
cachedAirportsByCountry[country.id] = airports
return airports
}
// MARK: - Private Helpers
private func makeRequest(url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.setValue("https://www.flightconnections.com/", forHTTPHeaderField: "Referer")
return request
}
private func perform(_ request: URLRequest) async throws -> Data {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw FlightServiceError.invalidResponse
}
guard (200...299).contains(http.statusCode) else {
throw FlightServiceError.httpError(http.statusCode)
}
return data
}
private func parseJSON(_ data: Data) throws -> Any {
do {
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
throw FlightServiceError.decodingFailed(error)
}
}
private func formatTime(_ raw: String) -> String {
let components = raw.split(separator: ":")
if components.count >= 2 {
return "\(components[0]):\(components[1])"
}
return raw
}
}
// MARK: - Concurrency Helper
private actor Counter {
private var value = 0
func increment() -> Int {
value += 1
return value
}
}
// MARK: - Errors
struct AutocompleteResults: Sendable {
let airports: [Airport]
let countries: [Country]
}
enum FlightServiceError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int)
case decodingFailed(Error)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid response from server"
case .httpError(let code):
return "HTTP error \(code)"
case .decodingFailed(let error):
return "Failed to decode response: \(error.localizedDescription)"
}
}
}