847e5c6035
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>
131 lines
5.3 KiB
Swift
131 lines
5.3 KiB
Swift
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))
|
||
}
|
||
}
|
||
}
|
||
}
|