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>
200 lines
7.8 KiB
Swift
200 lines
7.8 KiB
Swift
import SwiftUI
|
|
import EventKit
|
|
|
|
/// Scan-the-calendar import flow. Shows discovered flight-shaped events
|
|
/// as a checkable list; user toggles which to import, taps Import All,
|
|
/// and we route-explorer-autofill them in the background. Dedupes
|
|
/// against existing logs.
|
|
struct CalendarImportView: View {
|
|
let routeExplorer: RouteExplorerClient
|
|
let database: AirportDatabase
|
|
let store: FlightHistoryStore
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var phase: Phase = .askingPermission
|
|
@State private var candidates: [CalendarFlightImporter.Candidate] = []
|
|
@State private var selected: Set<UUID> = []
|
|
@State private var importing = false
|
|
@State private var importedCount = 0
|
|
|
|
enum Phase {
|
|
case askingPermission
|
|
case denied
|
|
case scanning
|
|
case ready
|
|
case importing
|
|
case done
|
|
}
|
|
|
|
private let importer = CalendarFlightImporter()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
content
|
|
.navigationTitle("Scan calendar")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
if phase == .ready && !selected.isEmpty {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button("Import \(selected.count)") {
|
|
Task { await importSelected() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.task(id: phase) {
|
|
if phase == .askingPermission {
|
|
let ok = await importer.requestAccess()
|
|
phase = ok ? .scanning : .denied
|
|
} else if phase == .scanning {
|
|
let cands = importer.scan()
|
|
// Pre-dedupe against existing log
|
|
let novel = cands.filter { c in
|
|
!store.exists(
|
|
flightDate: c.flightDate,
|
|
flightLabel: c.flightLabel,
|
|
departureIATA: c.departureIATA ?? "",
|
|
arrivalIATA: c.arrivalIATA ?? ""
|
|
)
|
|
}
|
|
candidates = novel.sorted { $0.flightDate > $1.flightDate }
|
|
selected = Set(candidates.map { $0.id })
|
|
phase = .ready
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
switch phase {
|
|
case .askingPermission, .scanning:
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
Text(phase == .askingPermission ? "Requesting access…" : "Scanning your calendar…")
|
|
.font(.subheadline)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
case .denied:
|
|
ContentUnavailableView(
|
|
"Calendar access denied",
|
|
systemImage: "calendar.badge.exclamationmark",
|
|
description: Text("Enable calendar access in Settings to scan for flight events.")
|
|
)
|
|
|
|
case .ready:
|
|
if candidates.isEmpty {
|
|
ContentUnavailableView(
|
|
"No new flights found",
|
|
systemImage: "calendar.badge.checkmark",
|
|
description: Text("Your calendar didn't have any flight-shaped events that aren't already in your log.")
|
|
)
|
|
} else {
|
|
List(candidates) { c in
|
|
Button {
|
|
toggle(c.id)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: selected.contains(c.id) ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(selected.contains(c.id) ? FlightTheme.accent : FlightTheme.textTertiary)
|
|
VStack(alignment: .leading) {
|
|
Text(c.flightLabel)
|
|
.font(.subheadline.weight(.bold).monospaced())
|
|
if let from = c.departureIATA, let to = c.arrivalIATA {
|
|
Text("\(from) → \(to)")
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
} else {
|
|
Text("Route TBD via lookup")
|
|
.font(.caption)
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
}
|
|
Spacer()
|
|
Text(shortDate(c.flightDate))
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
case .importing:
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
Text("Importing \(importedCount) / \(selected.count)…")
|
|
.font(.subheadline)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
case .done:
|
|
ContentUnavailableView(
|
|
"Imported \(importedCount) flights",
|
|
systemImage: "checkmark.circle.fill",
|
|
description: Text("Your log is up to date.")
|
|
)
|
|
}
|
|
}
|
|
|
|
private func toggle(_ id: UUID) {
|
|
if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
|
|
}
|
|
|
|
private func importSelected() async {
|
|
phase = .importing
|
|
importedCount = 0
|
|
for c in candidates where selected.contains(c.id) {
|
|
// Route-explorer enrichment when carrier + flight # are known.
|
|
var depIATA = c.departureIATA ?? ""
|
|
var arrIATA = c.arrivalIATA ?? ""
|
|
var sched: (dep: Date?, arr: Date?) = (nil, nil)
|
|
|
|
if let carrier = c.carrierIATA, let num = c.flightNumber.flatMap(Int.init) {
|
|
let day = Calendar.current.startOfDay(for: c.flightDate)
|
|
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
|
|
let results = await routeExplorer.searchSchedule(
|
|
carrierCode: carrier,
|
|
flightNumber: num,
|
|
startDate: day,
|
|
endDate: next
|
|
)
|
|
if let r = results.first {
|
|
if depIATA.isEmpty { depIATA = r.departure.airportIata }
|
|
if arrIATA.isEmpty { arrIATA = r.arrival.airportIata }
|
|
sched = (r.departure.dateTime, r.arrival.dateTime)
|
|
}
|
|
}
|
|
|
|
let icao = c.carrierIATA.flatMap { AircraftRegistry.shared.lookup(iata: $0)?.icao }
|
|
let flight = LoggedFlight(
|
|
flightDate: c.flightDate,
|
|
carrierICAO: icao,
|
|
carrierIATA: c.carrierIATA,
|
|
flightNumber: c.flightNumber,
|
|
departureIATA: depIATA,
|
|
arrivalIATA: arrIATA,
|
|
scheduledDeparture: sched.dep,
|
|
scheduledArrival: sched.arr,
|
|
source: "calendar"
|
|
)
|
|
store.save(flight)
|
|
importedCount += 1
|
|
}
|
|
phase = .done
|
|
}
|
|
|
|
private func shortDate(_ d: Date) -> String {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "MMM d, yyyy"
|
|
return f.string(from: d)
|
|
}
|
|
}
|