Files
Flights/Flights/Views/CalendarImportView.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

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)
}
}