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:
@@ -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 = "<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>"; };
|
||||
LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftPhotoService.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user