Detail sheet: hero aircraft photo via planespotters

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 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 08:45:28 -05:00
parent 92bc6ed52e
commit 16b874a7ad
3 changed files with 204 additions and 25 deletions
+4
View File
@@ -60,6 +60,7 @@
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; }; LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; }; LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; };
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -128,6 +129,7 @@
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; }; LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; }; LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = "<group>"; }; LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = "<group>"; };
LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftPhotoService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -249,6 +251,7 @@
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */, LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
LVDD000DDDD000DDDD000002 /* LocationService.swift */, LVDD000DDDD000DDDD000002 /* LocationService.swift */,
LVEE000EEEE000EEEE000002 /* FR24Client.swift */, LVEE000EEEE000EEEE000002 /* FR24Client.swift */,
LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -422,6 +425,7 @@
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */, LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */, LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */, LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */,
LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+117
View File
@@ -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
)
}
}
+83 -25
View File
@@ -9,6 +9,7 @@ struct LiveFlightDetailSheet: View {
@State private var recentFlights: [OpenSkyFlight] = [] @State private var recentFlights: [OpenSkyFlight] = []
@State private var isLoadingRoute = false @State private var isLoadingRoute = false
@State private var aircraftPhoto: AircraftPhotoService.Photo?
/// The resolved route for the current selection. Built from a cascade: /// The resolved route for the current selection. Built from a cascade:
/// scheduled flight (via route-explorer) OpenSky history trail-based /// scheduled flight (via route-explorer) OpenSky history trail-based
@@ -32,48 +33,57 @@ struct LiveFlightDetailSheet: View {
} }
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 0) {
header photoBanner
// Depart Arrival lives directly under the callsign VStack(alignment: .leading, spacing: 16) {
// header it's the single most important thing the header
// user opened the sheet to see.
routeSection
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") Divider()
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
liveStateGrid Text("LIVE STATE")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
if recentFlights.count > 1 { liveStateGrid
Text("RECENT FLIGHTS")
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()) .font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(FlightTheme.textTertiary)
.tracking(1) .tracking(1)
.padding(.top, 4) .padding(.top, 4)
ForEach(recentFlights.prefix(8), id: \.self) { flight in aircraftCard
recentFlightRow(flight)
if let photo = aircraftPhoto, let credit = photo.photographer {
photoCredit(name: credit, link: photo.detailLink)
} }
} }
.padding(16)
Text("AIRCRAFT")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
.padding(.top, 4)
aircraftCard
} }
.padding(16)
} }
.background(FlightTheme.background.ignoresSafeArea()) .background(FlightTheme.background.ignoresSafeArea())
.toolbar { .toolbar {
@@ -87,6 +97,12 @@ struct LiveFlightDetailSheet: View {
.task { .task {
await resolveRoute() 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 // MARK: - Header
private var header: some View { private var header: some View {