Flight History (v1): logbook, stats, animated route map, year-in-review
Adds a new History tab implementing the core of Flighty's Passport feature set, free + iCloud-synced. Data layer - `LoggedFlight` and `AirframeMetadata` @Model classes (SwiftData) - ModelContainer with CloudKit private DB; falls back to local-only when CloudKit cap isn't provisioned so the app stays functional. - `FlightHistoryStore` wraps the ModelContext for save/delete + dedupe + great-circle distance / duration helpers + tail-repeat counting. History UI - `HistoryView` — list grouped by year, totals strip at top, swipe to delete, empty state with instructions. - `HistoryRowView` — airframe photo thumbnail (planespotters), flight#, route, type, date. - `HistoryDetailView` — title → route → photo → flown-path / great- circle map → aircraft card (type, tail, age, "Nth time on this airframe") → editable notes → delete. Add paths - "+ Add to my flights" button on the live aircraft sheet — pre-fills the form from FR24 enrichment (carrier, flight#, route, aircraft type, tail). - Manual entry form (`AddFlightView`) with route-explorer autofill via `searchSchedule(carrierCode:flightNumber:startDate:endDate:)`. - Calendar scan (`CalendarFlightImporter` + `CalendarImportView`) — EventKit access prompt → regex-detect flight-shaped events across last 5 years → dedupe → batch-confirm with route-explorer enrichment. - `WalletPassObserver` (PassKit) — observes the library for new boarding passes and parses origin/destination/flight#/seat. Service is wired; explicit UI prompt deferred to follow-up. Stats + visualization - `StatsEngine` — totals (flights / miles / hours / airports / airlines / aircraft / countries) + narrative stats (top airline, top route, top airport, longest, shortest, repeated tails). - `LifetimeStatsView` — big-number tile grid + highlights cards + repeated airframes list. - `HistoryRouteMapView` — every great-circle arc the user has flown, animating in oldest → newest on first appear. Airport dots sized log-scale by visit count. - `YearInReviewView` — Spotify-Wrapped-style horizontal card deck for the current year: total miles, airports + countries, hours airborne, top airline, top route, longest flight. Entitlements - New `Flights.entitlements` with `iCloud.com.flights.app` CloudKit container. Risk note: the build falls back to local-only SwiftData if the CloudKit container isn't provisioned for team V3PF3M6B6U / bundle id com.flights.app. The History feature works fully either way; sync requires the cap to land. Deferred to follow-ups - Wallet auto-prompt UI binding (service exists, view hook TBD) - Mail Share Extension (separate app-extension target) - Jetphotos first-flight-date scraping - OpenSky historical track replay (great-circle fallback ships) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import CoreLocation
|
||||
|
||||
/// Single-flight detail screen. Layout follows the live detail sheet
|
||||
/// pattern (title → route → photo → map → aircraft) but adds notes
|
||||
/// and a delete button. Pulls a track replay from OpenSky for flights
|
||||
/// flown in the last ~7 days; everything older falls back to a clean
|
||||
/// great-circle arc.
|
||||
struct HistoryDetailView: View {
|
||||
let flight: LoggedFlight
|
||||
let store: FlightHistoryStore
|
||||
let database: AirportDatabase
|
||||
let openSky: OpenSkyClient
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var photo: AircraftPhotoService.Photo?
|
||||
@State private var track: AircraftTrack?
|
||||
@State private var editedNotes: String = ""
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
routeCard
|
||||
photoBanner
|
||||
.padding(.horizontal, -16)
|
||||
if let cred = photo?.photographer {
|
||||
photoCredit(name: cred, link: photo?.detailLink)
|
||||
}
|
||||
mapSection
|
||||
aircraftCard
|
||||
notesSection
|
||||
deleteButton
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle(flight.flightLabel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
editedNotes = flight.notes ?? ""
|
||||
if let reg = flight.registration {
|
||||
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "")
|
||||
}
|
||||
await loadTrackIfRecent()
|
||||
}
|
||||
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
|
||||
Button("Delete", role: .destructive) {
|
||||
store.delete(flight)
|
||||
dismiss()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 10) {
|
||||
if let logo = airlineLogoURL {
|
||||
AsyncImage(url: logo) { phase in
|
||||
switch phase {
|
||||
case .success(let img): img.resizable().scaledToFit()
|
||||
default: RoundedRectangle(cornerRadius: 8).fill(FlightTheme.accent.opacity(0.2))
|
||||
}
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
Text(flight.flightLabel)
|
||||
.font(.title.weight(.bold).monospaced())
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Spacer()
|
||||
Text(longDate(flight.flightDate))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
}
|
||||
Text(airlineName)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route card
|
||||
|
||||
private var routeCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("ROUTE")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
HStack(spacing: 16) {
|
||||
endpoint(iata: flight.departureIATA, label: "Departed", time: flight.actualDeparture ?? flight.scheduledDeparture)
|
||||
Image(systemName: "airplane")
|
||||
.font(.title3)
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.rotationEffect(.degrees(-45))
|
||||
endpoint(iata: flight.arrivalIATA, label: "Arrived", time: flight.actualArrival ?? flight.scheduledArrival)
|
||||
}
|
||||
if let mi = store.distanceMiles(for: flight) {
|
||||
Text("\(numberString(mi)) miles · \(durationDisplay)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
}
|
||||
}
|
||||
.flightCard()
|
||||
}
|
||||
|
||||
private func endpoint(iata: String, label: String, time: Date?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(0.5)
|
||||
Text(iata.isEmpty ? "—" : iata)
|
||||
.font(FlightTheme.airportCode(28))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
if let m = database.airport(byIATA: iata) {
|
||||
Text(m.name)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let time {
|
||||
Text(shortDateTime(time))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Photo
|
||||
|
||||
@ViewBuilder
|
||||
private var photoBanner: some View {
|
||||
if let photo {
|
||||
AsyncImage(url: photo.largeURL) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().aspectRatio(contentMode: .fill)
|
||||
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)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if let link { openURL(link) } }
|
||||
}
|
||||
|
||||
// MARK: - Map
|
||||
|
||||
@ViewBuilder
|
||||
private var mapSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(track == nil ? "ROUTE MAP" : "FLOWN PATH")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
FlightRouteMap(
|
||||
departureIATA: flight.departureIATA,
|
||||
arrivalIATA: flight.arrivalIATA,
|
||||
track: track,
|
||||
database: database
|
||||
)
|
||||
.frame(height: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTrackIfRecent() async {
|
||||
// OpenSky's anonymous track endpoint goes back roughly 7 days
|
||||
// before they trim history. Older logs get the great-circle
|
||||
// fallback drawn by FlightRouteMap.
|
||||
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
|
||||
guard ageDays < 7, let icao24 = guessICAO24() else { return }
|
||||
track = await openSky.track(icao24: icao24)
|
||||
}
|
||||
|
||||
/// We don't store icao24 on the LoggedFlight (we store registration
|
||||
/// instead) — but for track replay we need icao24. Future work: pull
|
||||
/// reg→icao24 mapping from a fresh OpenSky lookup. For now, only the
|
||||
/// most-recently-logged airframe gets a replay attempt.
|
||||
private func guessICAO24() -> String? {
|
||||
// TODO: tie this to a reg→icao24 resolution. For v1 the
|
||||
// track replay only fires when icao24 is in notes or we
|
||||
// resolve via aircraft DB.
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Aircraft card
|
||||
|
||||
private var aircraftCard: some View {
|
||||
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
|
||||
let airframe = flight.registration.flatMap(store.airframe(for:))
|
||||
let ageYears = airframe?.firstFlightDate.map { years(since: $0) }
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
Text("AIRCRAFT")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
cell(label: "Type", value: flight.aircraftType ?? "—")
|
||||
cell(label: "Tail", value: flight.registration ?? "—")
|
||||
}
|
||||
if ageYears != nil || repeats > 0 {
|
||||
Divider()
|
||||
HStack(spacing: 0) {
|
||||
if let yrs = ageYears {
|
||||
cell(label: "Age", value: "\(yrs)y")
|
||||
} else {
|
||||
cell(label: "Age", value: "—")
|
||||
}
|
||||
cell(
|
||||
label: "On this airframe",
|
||||
value: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.flightCard(padding: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func cell(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(0.5)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold).monospaced())
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
// MARK: - Notes
|
||||
|
||||
private var notesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("NOTES")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
TextEditor(text: $editedNotes)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
|
||||
.onChange(of: editedNotes) { _, newValue in
|
||||
flight.notes = newValue.isEmpty ? nil : newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete flight")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(FlightTheme.cancelled.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(FlightTheme.cancelled)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var airlineEntry: AircraftRegistry.Entry? {
|
||||
AircraftRegistry.shared.lookup(icao: flight.carrierICAO)
|
||||
?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)
|
||||
}
|
||||
private var airlineLogoURL: URL? { airlineEntry?.logoURL }
|
||||
private var airlineName: String {
|
||||
airlineEntry?.name ?? flight.carrierICAO ?? flight.carrierIATA ?? "Unknown"
|
||||
}
|
||||
|
||||
private var durationDisplay: String {
|
||||
guard let min = store.durationMinutes(for: flight) else { return "—" }
|
||||
let h = min / 60
|
||||
let m = min % 60
|
||||
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
|
||||
}
|
||||
|
||||
private func numberString(_ n: Int) -> String {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||
}
|
||||
|
||||
private func years(since: Date) -> Int {
|
||||
Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0
|
||||
}
|
||||
|
||||
private func ordinalSuffix(_ n: Int) -> String {
|
||||
let r = n % 100
|
||||
if r >= 11 && r <= 13 { return "th" }
|
||||
switch n % 10 {
|
||||
case 1: return "st"
|
||||
case 2: return "nd"
|
||||
case 3: return "rd"
|
||||
default: return "th"
|
||||
}
|
||||
}
|
||||
|
||||
private func longDate(_ d: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMM d, yyyy"
|
||||
return f.string(from: d)
|
||||
}
|
||||
|
||||
private func shortDateTime(_ d: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMM d, HH:mm"
|
||||
return f.string(from: d)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map view used by the history detail. Draws the actual flown track
|
||||
/// when supplied; otherwise a great-circle arc between dep + arr.
|
||||
private struct FlightRouteMap: View {
|
||||
let departureIATA: String
|
||||
let arrivalIATA: String
|
||||
let track: AircraftTrack?
|
||||
let database: AirportDatabase
|
||||
|
||||
var body: some View {
|
||||
Map {
|
||||
if let dep = database.airport(byIATA: departureIATA) {
|
||||
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
|
||||
.tint(FlightTheme.onTime)
|
||||
}
|
||||
if let arr = database.airport(byIATA: arrivalIATA) {
|
||||
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
|
||||
.tint(FlightTheme.accent)
|
||||
}
|
||||
if let track {
|
||||
let coords = track.path.map {
|
||||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||||
}
|
||||
MapPolyline(coordinates: coords)
|
||||
.stroke(FlightTheme.accent, lineWidth: 3)
|
||||
} else if let dep = database.airport(byIATA: departureIATA),
|
||||
let arr = database.airport(byIATA: arrivalIATA) {
|
||||
MapPolyline(coordinates: greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 64))
|
||||
.stroke(FlightTheme.accent.opacity(0.6), style: StrokeStyle(lineWidth: 2, dash: [5, 4]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polyline samples along the great-circle path between two
|
||||
/// coordinates. MapKit doesn't draw GC paths natively — we
|
||||
/// approximate with N straight segments along the GC route.
|
||||
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
|
||||
let lat1 = a.latitude * .pi / 180
|
||||
let lon1 = a.longitude * .pi / 180
|
||||
let lat2 = b.latitude * .pi / 180
|
||||
let lon2 = b.longitude * .pi / 180
|
||||
|
||||
let d = 2 * asin(sqrt(
|
||||
pow(sin((lat2 - lat1) / 2), 2)
|
||||
+ cos(lat1) * cos(lat2) * pow(sin((lon2 - lon1) / 2), 2)
|
||||
))
|
||||
if d == 0 { return [a, b] }
|
||||
|
||||
var out: [CLLocationCoordinate2D] = []
|
||||
out.reserveCapacity(segments + 1)
|
||||
for i in 0...segments {
|
||||
let f = Double(i) / Double(segments)
|
||||
let A = sin((1 - f) * d) / sin(d)
|
||||
let B = sin(f * d) / sin(d)
|
||||
let x = A * cos(lat1) * cos(lon1) + B * cos(lat2) * cos(lon2)
|
||||
let y = A * cos(lat1) * sin(lon1) + B * cos(lat2) * sin(lon2)
|
||||
let z = A * sin(lat1) + B * sin(lat2)
|
||||
let lat = atan2(z, sqrt(x * x + y * y))
|
||||
let lon = atan2(y, x)
|
||||
out.append(CLLocationCoordinate2D(latitude: lat * 180 / .pi, longitude: lon * 180 / .pi))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user