From 16b874a7ad4aebfc98a539cfa0e92e48eeb1e622 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 27 May 2026 08:45:28 -0500 Subject: [PATCH] Detail sheet: hero aircraft photo via planespotters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 200pt-tall hero image at the top of the detail sheet showing the actual airframe (registration-keyed), surfacing special liveries naturally — photographers chase one-off paint jobs first, so the most recent photo is usually the most recent livery scheme. AircraftPhotoService wraps planespotters.net's public API: - Lookup by registration (FR24 enrichment) first - Falls back to ICAO24 hex when no registration - In-memory cache (hits and misses) so we never re-query the same airframe twice in a session - User-Agent includes contact URL per planespotters' TOS - Photographer attribution rendered in the AIRCRAFT section, tap to open the planespotters page Sheet hides the banner entirely when no photo exists. Co-Authored-By: Claude Opus 4.7 --- Flights.xcodeproj/project.pbxproj | 4 + Flights/Services/AircraftPhotoService.swift | 117 ++++++++++++++++++++ Flights/Views/LiveFlightDetailSheet.swift | 108 +++++++++++++----- 3 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 Flights/Services/AircraftPhotoService.swift diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 377d64f..21b0628 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; }; LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; }; LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.swift */; }; + LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -128,6 +129,7 @@ LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = ""; }; LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = ""; }; + LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftPhotoService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -249,6 +251,7 @@ LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */, LVDD000DDDD000DDDD000002 /* LocationService.swift */, LVEE000EEEE000EEEE000002 /* FR24Client.swift */, + LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */, ); path = Services; sourceTree = ""; @@ -422,6 +425,7 @@ LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */, LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */, LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */, + LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Flights/Services/AircraftPhotoService.swift b/Flights/Services/AircraftPhotoService.swift new file mode 100644 index 0000000..f16e70a --- /dev/null +++ b/Flights/Services/AircraftPhotoService.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Aircraft photo lookup, backed by planespotters.net's public API. +/// +/// Two lookup paths, tried in order: +/// 1. By registration / tail number (e.g. "N971NN") — preferred, since +/// FR24's feed gives us this inline. +/// 2. By 24-bit ICAO transponder hex (e.g. "ad8895") — fallback when +/// we don't have a registration (most GA / military aircraft). +/// +/// Planespotters serves the most recent photo of that airframe — which +/// naturally surfaces special liveries, since photographers prioritize +/// catching one-off paint schemes the moment they appear. +/// +/// Their TOS requires: +/// - A User-Agent with a contact URL. +/// - Photographer attribution wherever the photo is shown. +/// Both are honored here. +actor AircraftPhotoService { + static let shared = AircraftPhotoService() + + struct Photo: Hashable, Sendable { + let thumbnailURL: URL // ~200×112 + let largeURL: URL // ~497×280 + let detailLink: URL? // planespotters page (attribution requires linking) + let photographer: String? + } + + private var cache: [String: Photo?] = [:] + private let session: URLSession + + private init(session: URLSession = .shared) { + self.session = session + } + + /// Returns the most recent photo for an airframe, or nil if none. + /// Results (hits AND misses) are cached for the lifetime of the app + /// so we never re-hit planespotters for the same airframe twice. + func photo(registration: String?, icao24: String) async -> Photo? { + let key = (registration?.uppercased()) + ?? icao24.uppercased() + if let cached = cache[key] { return cached } + + // 1) Try registration. Planespotters indexes by tail number and + // most operators are accurately mapped. + if let reg = registration?.uppercased(), !reg.isEmpty { + if let p = await fetch(path: "reg/\(reg)") { + cache[key] = p + return p + } + } + // 2) Fall back to ICAO24 hex — slower index but catches some + // airframes that don't have a current registration on file. + let hexKey = icao24.uppercased() + if let p = await fetch(path: "hex/\(hexKey)") { + cache[key] = p + return p + } + // Cache the miss so we don't re-query. + cache[key] = .some(nil) + return nil + } + + private func fetch(path: String) async -> Photo? { + guard let url = URL(string: "https://api.planespotters.net/pub/photos/\(path)") else { + return nil + } + var req = URLRequest(url: url) + req.timeoutInterval = 8 + // Planespotters' TOS requires a contact URL in the User-Agent + // string. Without it the API returns an error blob. + req.setValue( + "Flights/1.0 (+https://github.com/admin/Flights)", + forHTTPHeaderField: "User-Agent" + ) + req.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, resp) = try await session.data(for: req) + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + return nil + } + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let photos = root["photos"] as? [[String: Any]], + let first = photos.first + else { return nil } + return Self.parse(first) + } catch { + return nil + } + } + + private static func parse(_ row: [String: Any]) -> Photo? { + guard let thumbObj = row["thumbnail_large"] as? [String: Any] ?? row["thumbnail"] as? [String: Any], + let thumbSrc = thumbObj["src"] as? String, + let thumbURL = URL(string: thumbSrc) + else { return nil } + + let large: URL = { + if let lg = row["thumbnail_large"] as? [String: Any], + let s = lg["src"] as? String, + let u = URL(string: s) { return u } + return thumbURL + }() + + let link = (row["link"] as? String).flatMap(URL.init(string:)) + let photographer = (row["photographer"] as? String) + .flatMap { $0.isEmpty ? nil : $0 } + + return Photo( + thumbnailURL: thumbURL, + largeURL: large, + detailLink: link, + photographer: photographer + ) + } +} diff --git a/Flights/Views/LiveFlightDetailSheet.swift b/Flights/Views/LiveFlightDetailSheet.swift index a46c850..85a9f86 100644 --- a/Flights/Views/LiveFlightDetailSheet.swift +++ b/Flights/Views/LiveFlightDetailSheet.swift @@ -9,6 +9,7 @@ struct LiveFlightDetailSheet: View { @State private var recentFlights: [OpenSkyFlight] = [] @State private var isLoadingRoute = false + @State private var aircraftPhoto: AircraftPhotoService.Photo? /// The resolved route for the current selection. Built from a cascade: /// scheduled flight (via route-explorer) → OpenSky history → trail-based @@ -32,48 +33,57 @@ struct LiveFlightDetailSheet: View { } @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL var body: some View { NavigationStack { ScrollView { - VStack(alignment: .leading, spacing: 16) { - header + VStack(alignment: .leading, spacing: 0) { + photoBanner - // Depart → Arrival lives directly under the callsign - // header — it's the single most important thing the - // user opened the sheet to see. - routeSection + VStack(alignment: .leading, spacing: 16) { + header - Divider() + // Depart → Arrival lives directly under the callsign + // header — it's the single most important thing the + // user opened the sheet to see. + routeSection - Text("LIVE STATE") - .font(FlightTheme.label()) - .foregroundStyle(FlightTheme.textTertiary) - .tracking(1) + Divider() - liveStateGrid + Text("LIVE STATE") + .font(FlightTheme.label()) + .foregroundStyle(FlightTheme.textTertiary) + .tracking(1) - if recentFlights.count > 1 { - Text("RECENT FLIGHTS") + liveStateGrid + + if recentFlights.count > 1 { + Text("RECENT FLIGHTS") + .font(FlightTheme.label()) + .foregroundStyle(FlightTheme.textTertiary) + .tracking(1) + .padding(.top, 4) + + ForEach(recentFlights.prefix(8), id: \.self) { flight in + recentFlightRow(flight) + } + } + + Text("AIRCRAFT") .font(FlightTheme.label()) .foregroundStyle(FlightTheme.textTertiary) .tracking(1) .padding(.top, 4) - ForEach(recentFlights.prefix(8), id: \.self) { flight in - recentFlightRow(flight) + aircraftCard + + if let photo = aircraftPhoto, let credit = photo.photographer { + photoCredit(name: credit, link: photo.detailLink) } } - - Text("AIRCRAFT") - .font(FlightTheme.label()) - .foregroundStyle(FlightTheme.textTertiary) - .tracking(1) - .padding(.top, 4) - - aircraftCard + .padding(16) } - .padding(16) } .background(FlightTheme.background.ignoresSafeArea()) .toolbar { @@ -87,6 +97,12 @@ struct LiveFlightDetailSheet: View { .task { await resolveRoute() } + .task(id: aircraft.icao24) { + aircraftPhoto = await AircraftPhotoService.shared.photo( + registration: aircraft.enrichment?.registration, + icao24: aircraft.icao24 + ) + } } } @@ -188,6 +204,48 @@ struct LiveFlightDetailSheet: View { } } + // MARK: - Photo banner + // + // Hero image at the very top of the sheet, sourced from planespotters. + // Hides itself entirely when no photo is available (lots of GA / cargo + // / older airframes have no public photo). Most recent photo per + // airframe is what planespotters serves — which means special + // liveries surface naturally because photographers chase them first. + + @ViewBuilder + private var photoBanner: some View { + if let photo = aircraftPhoto { + AsyncImage(url: photo.largeURL) { phase in + switch phase { + case .success(let img): + img.resizable().aspectRatio(contentMode: .fill) + case .empty, .failure: + Rectangle().fill(FlightTheme.cardBackground) + @unknown default: + Rectangle().fill(FlightTheme.cardBackground) + } + } + .frame(maxWidth: .infinity) + .frame(height: 200) + .clipped() + } + } + + private func photoCredit(name: String, link: URL?) -> some View { + HStack(spacing: 4) { + Image(systemName: "camera.fill") + .font(.caption2) + Text("Photo by \(name) · planespotters.net") + .font(.caption2) + } + .foregroundStyle(FlightTheme.textTertiary) + .padding(.top, 4) + .contentShape(Rectangle()) + .onTapGesture { + if let link { openURL(link) } + } + } + // MARK: - Header private var header: some View {