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:
Trey T
2026-05-27 09:34:38 -05:00
parent a1831d0034
commit 847e5c6035
20 changed files with 2222 additions and 1 deletions
+408
View File
@@ -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
/// regicao24 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 regicao24 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
}
}