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 */; };
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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,10 +33,14 @@ 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: 0) {
|
||||||
|
photoBanner
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
header
|
header
|
||||||
|
|
||||||
@@ -72,9 +77,14 @@ struct LiveFlightDetailSheet: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
aircraftCard
|
aircraftCard
|
||||||
|
|
||||||
|
if let photo = aircraftPhoto, let credit = photo.photographer {
|
||||||
|
photoCredit(name: credit, link: photo.detailLink)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.background(FlightTheme.background.ignoresSafeArea())
|
.background(FlightTheme.background.ignoresSafeArea())
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user