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>
88 lines
2.9 KiB
Swift
88 lines
2.9 KiB
Swift
import SwiftUI
|
|
|
|
/// Single row in the history list. Loads the airframe photo
|
|
/// asynchronously and renders a thumb on the left, flight identity in
|
|
/// the middle, date on the right.
|
|
struct HistoryRowView: View {
|
|
let flight: LoggedFlight
|
|
let database: AirportDatabase
|
|
|
|
@State private var photo: AircraftPhotoService.Photo?
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
thumbnail
|
|
.frame(width: 64, height: 48)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Text(flight.flightLabel)
|
|
.font(.subheadline.weight(.bold).monospaced())
|
|
.foregroundStyle(FlightTheme.textPrimary)
|
|
if let type = flight.aircraftType {
|
|
Text("· \(type)")
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
}
|
|
HStack(spacing: 6) {
|
|
Text(flight.departureIATA)
|
|
.font(.caption.weight(.semibold).monospaced())
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
Image(systemName: "arrow.right")
|
|
.font(.caption2)
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
Text(flight.arrivalIATA)
|
|
.font(.caption.weight(.semibold).monospaced())
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(shortDate(flight.flightDate))
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.task(id: flight.registration ?? flight.id.uuidString) {
|
|
guard let reg = flight.registration, !reg.isEmpty else { return }
|
|
photo = await AircraftPhotoService.shared.photo(
|
|
registration: reg,
|
|
icao24: ""
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var thumbnail: some View {
|
|
if let url = photo?.thumbnailURL {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let img):
|
|
img.resizable().aspectRatio(contentMode: .fill)
|
|
default:
|
|
placeholder
|
|
}
|
|
}
|
|
} else {
|
|
placeholder
|
|
}
|
|
}
|
|
|
|
private var placeholder: some View {
|
|
ZStack {
|
|
FlightTheme.cardBackground
|
|
Image(systemName: "airplane")
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
}
|
|
|
|
private func shortDate(_ d: Date) -> String {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "MMM d"
|
|
return f.string(from: d)
|
|
}
|
|
}
|