Files
Flights/Flights/Views/LifetimeStatsView.swift
T
Trey T 847e5c6035 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>
2026-05-27 09:34:38 -05:00

131 lines
5.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// Lifetime stats screen. Big-number tiles up top, narrative stats
/// below. Pure read-only.
struct LifetimeStatsView: View {
let stats: StatsEngine
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
tilesGrid
narrativeSection
repeatedTailsSection
}
.padding(16)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Lifetime")
.navigationBarTitleDisplayMode(.inline)
}
private var tilesGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 2), spacing: 10) {
tile(label: "Flights", value: "\(stats.totalFlights)")
tile(label: "Miles", value: stats.shortDistance)
tile(label: "Hours", value: stats.shortDuration)
tile(label: "Airports", value: "\(stats.uniqueAirports)")
tile(label: "Airlines", value: "\(stats.uniqueAirlines)")
tile(label: "Aircraft", value: "\(stats.uniqueAircraftTypes)")
tile(label: "Countries", value: "\(stats.uniqueCountries)")
}
}
private func tile(label: String, value: String) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 32, weight: .bold).monospacedDigit())
.foregroundStyle(FlightTheme.textPrimary)
Text(label.uppercased())
.font(.caption.weight(.semibold))
.tracking(0.8)
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
}
private var narrativeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("HIGHLIGHTS")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
VStack(spacing: 0) {
if let top = stats.topAirline {
statRow(label: "Most-flown airline", value: AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao, count: top.count)
Divider()
}
if let route = stats.topRoute {
statRow(label: "Most-flown route", value: route.label, count: route.count)
Divider()
}
if let airport = stats.topAirport {
statRow(label: "Most-visited airport", value: airport.iata, count: airport.count)
Divider()
}
if let longest = stats.longestFlight {
statRow(label: "Longest flight", value: "\(longest.departureIATA)\(longest.arrivalIATA)", count: nil)
Divider()
}
if let shortest = stats.shortestFlight {
statRow(label: "Shortest flight", value: "\(shortest.departureIATA)\(shortest.arrivalIATA)", count: nil)
}
}
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
}
}
private func statRow(label: String, value: String, count: Int?) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
}
Spacer()
if let count {
Text("\(count)×")
.font(.subheadline.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.accent)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
@ViewBuilder
private var repeatedTailsSection: some View {
let tails = stats.repeatedTails.prefix(8)
if !tails.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("AIRFRAMES YOU'VE REPEATED")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
VStack(spacing: 0) {
ForEach(Array(tails.enumerated()), id: \.offset) { index, item in
HStack {
Text(item.reg)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text("\(item.count) flights")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
if index < tails.count - 1 { Divider() }
}
}
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
}
}
}
}