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>
This commit is contained in:
Trey T
2026-05-27 09:34:38 -05:00
parent a1831d0034
commit 847e5c6035
20 changed files with 2222 additions and 1 deletions
+3
View File
@@ -44,3 +44,6 @@ airlines/
# Claude # Claude
.claude/ .claude/
# Playwright MCP scratch captures
.playwright-mcp/
+59
View File
@@ -61,6 +61,20 @@
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; }; LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; };
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.swift */; }; LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.swift */; };
LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */; }; LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */; };
HX0100001111000011110001 /* LoggedFlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0100001111000011110002 /* LoggedFlight.swift */; };
HX0200002222000022220001 /* AirframeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0200002222000022220002 /* AirframeMetadata.swift */; };
HX0300003333000033330001 /* FlightHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0300003333000033330002 /* FlightHistoryStore.swift */; };
HX0500005555000055550001 /* StatsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0500005555000055550002 /* StatsEngine.swift */; };
HX0600006666000066660001 /* CalendarFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0600006666000066660002 /* CalendarFlightImporter.swift */; };
HX0700007777000077770001 /* WalletPassObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0700007777000077770002 /* WalletPassObserver.swift */; };
HX0800008888000088880001 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0800008888000088880002 /* HistoryView.swift */; };
HX0900009999000099990001 /* HistoryRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0900009999000099990002 /* HistoryRowView.swift */; };
HX0A000AAAA000AAAA000001 /* HistoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0A000AAAA000AAAA000002 /* HistoryDetailView.swift */; };
HX0B000BBBB000BBBB000001 /* AddFlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0B000BBBB000BBBB000002 /* AddFlightView.swift */; };
HX0C000CCCC000CCCC000001 /* CalendarImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0C000CCCC000CCCC000002 /* CalendarImportView.swift */; };
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */; };
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; };
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -130,6 +144,21 @@
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; }; LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = "<group>"; }; LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = "<group>"; };
LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftPhotoService.swift; sourceTree = "<group>"; }; LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftPhotoService.swift; sourceTree = "<group>"; };
HX0100001111000011110002 /* LoggedFlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedFlight.swift; sourceTree = "<group>"; };
HX0200002222000022220002 /* AirframeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadata.swift; sourceTree = "<group>"; };
HX0300003333000033330002 /* FlightHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightHistoryStore.swift; sourceTree = "<group>"; };
HX0400004444000044440002 /* Flights.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Flights.entitlements; sourceTree = "<group>"; };
HX0500005555000055550002 /* StatsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsEngine.swift; sourceTree = "<group>"; };
HX0600006666000066660002 /* CalendarFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarFlightImporter.swift; sourceTree = "<group>"; };
HX0700007777000077770002 /* WalletPassObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletPassObserver.swift; sourceTree = "<group>"; };
HX0800008888000088880002 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
HX0900009999000099990002 /* HistoryRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRowView.swift; sourceTree = "<group>"; };
HX0A000AAAA000AAAA000002 /* HistoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryDetailView.swift; sourceTree = "<group>"; };
HX0B000BBBB000BBBB000002 /* AddFlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFlightView.swift; sourceTree = "<group>"; };
HX0C000CCCC000CCCC000002 /* CalendarImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarImportView.swift; sourceTree = "<group>"; };
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifetimeStatsView.swift; sourceTree = "<group>"; };
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = "<group>"; };
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -168,6 +197,14 @@
LV6600006666000066660002 /* RootView.swift */, LV6600006666000066660002 /* RootView.swift */,
LV8800008888000088880002 /* OpenSkySettingsView.swift */, LV8800008888000088880002 /* OpenSkySettingsView.swift */,
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */, LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */,
HX0800008888000088880002 /* HistoryView.swift */,
HX0900009999000099990002 /* HistoryRowView.swift */,
HX0A000AAAA000AAAA000002 /* HistoryDetailView.swift */,
HX0B000BBBB000BBBB000002 /* AddFlightView.swift */,
HX0C000CCCC000CCCC000002 /* CalendarImportView.swift */,
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */,
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
AA5555555555555555555555 /* Styles */, AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */, AA6666666666666666666666 /* Components */,
); );
@@ -252,6 +289,10 @@
LVDD000DDDD000DDDD000002 /* LocationService.swift */, LVDD000DDDD000DDDD000002 /* LocationService.swift */,
LVEE000EEEE000EEEE000002 /* FR24Client.swift */, LVEE000EEEE000EEEE000002 /* FR24Client.swift */,
LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */, LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */,
HX0300003333000033330002 /* FlightHistoryStore.swift */,
HX0500005555000055550002 /* StatsEngine.swift */,
HX0600006666000066660002 /* CalendarFlightImporter.swift */,
HX0700007777000077770002 /* WalletPassObserver.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -280,6 +321,8 @@
RE1100001111000011110002 /* RouteExplorerModels.swift */, RE1100001111000011110002 /* RouteExplorerModels.swift */,
RE8800008888000088880002 /* SearchRoute.swift */, RE8800008888000088880002 /* SearchRoute.swift */,
LV1100001111000011110002 /* LiveAircraft.swift */, LV1100001111000011110002 /* LiveAircraft.swift */,
HX0100001111000011110002 /* LoggedFlight.swift */,
HX0200002222000022220002 /* AirframeMetadata.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -426,6 +469,20 @@
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */, LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */, LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */,
LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */, LVFF000FFFF000FFFF000001 /* AircraftPhotoService.swift in Sources */,
HX0100001111000011110001 /* LoggedFlight.swift in Sources */,
HX0200002222000022220001 /* AirframeMetadata.swift in Sources */,
HX0300003333000033330001 /* FlightHistoryStore.swift in Sources */,
HX0500005555000055550001 /* StatsEngine.swift in Sources */,
HX0600006666000066660001 /* CalendarFlightImporter.swift in Sources */,
HX0700007777000077770001 /* WalletPassObserver.swift in Sources */,
HX0800008888000088880001 /* HistoryView.swift in Sources */,
HX0900009999000099990001 /* HistoryRowView.swift in Sources */,
HX0A000AAAA000AAAA000001 /* HistoryDetailView.swift in Sources */,
HX0B000BBBB000BBBB000001 /* AddFlightView.swift in Sources */,
HX0C000CCCC000CCCC000001 /* CalendarImportView.swift in Sources */,
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */,
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */,
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -445,6 +502,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Flights/Flights.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U; DEVELOPMENT_TEAM = V3PF3M6B6U;
@@ -475,6 +533,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Flights/Flights.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V3PF3M6B6U; DEVELOPMENT_TEAM = V3PF3M6B6U;
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.flights.app</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
</plist>
+31
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SwiftData
@main @main
struct FlightsApp: App { struct FlightsApp: App {
@@ -8,6 +9,12 @@ struct FlightsApp: App {
let openSky = OpenSkyClient() let openSky = OpenSkyClient()
let fr24 = FR24Client() let fr24 = FR24Client()
/// SwiftData container for the personal flight log. Uses CloudKit
/// private DB so the log syncs across the user's devices. Falls
/// back to a local-only store if CloudKit isn't provisioned (which
/// keeps the app functional during initial dev / first deploy).
let modelContainer: ModelContainer
init() { init() {
let db = AirportDatabase() let db = AirportDatabase()
self.database = db self.database = db
@@ -18,6 +25,29 @@ struct FlightsApp: App {
// jank the UI if we wait until first access on the Live tab. // jank the UI if we wait until first access on the Live tab.
AircraftRegistry.shared.preload() AircraftRegistry.shared.preload()
AircraftDatabase.shared.preload() AircraftDatabase.shared.preload()
// SwiftData + CloudKit. If the CloudKit container isn't
// available (cap not provisioned, simulator-only, etc.) we
// fall back to a local-only container so the rest of the app
// still works.
let schema = Schema([LoggedFlight.self, AirframeMetadata.self])
let cloudConfig = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .private("iCloud.com.flights.app")
)
let localConfig = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none
)
if let cloud = try? ModelContainer(for: schema, configurations: [cloudConfig]) {
self.modelContainer = cloud
} else {
// Local-only fallback. Logs persist on this device but
// don't sync.
self.modelContainer = try! ModelContainer(for: schema, configurations: [localConfig])
}
} }
var body: some Scene { var body: some Scene {
@@ -30,5 +60,6 @@ struct FlightsApp: App {
fr24: fr24 fr24: fr24
) )
} }
.modelContainer(modelContainer)
} }
} }
+27
View File
@@ -0,0 +1,27 @@
import Foundation
import SwiftData
/// Per-airframe enrichment cached locally (and synced via CloudKit so we
/// only scrape jetphotos once per airframe across all of a user's
/// devices). Keyed by registration. Currently captures first-flight /
/// delivery dates so we can render "this plane is 8 years old" in the
/// detail sheet.
@Model
final class AirframeMetadata {
var registration: String = "" // "N281WN" uppercase
var firstFlightDate: Date?
var deliveryDate: Date?
var scrapedAt: Date = Date()
init(
registration: String,
firstFlightDate: Date? = nil,
deliveryDate: Date? = nil,
scrapedAt: Date = Date()
) {
self.registration = registration.uppercased()
self.firstFlightDate = firstFlightDate
self.deliveryDate = deliveryDate
self.scrapedAt = scrapedAt
}
}
+84
View File
@@ -0,0 +1,84 @@
import Foundation
import SwiftData
/// A single flight the user has flown (or is flying), persisted to
/// SwiftData and synced via CloudKit private DB. Intentionally lean
/// we don't track baggage / terminal / gate / seat / cabin class /
/// delay reason. Just identity, route, aircraft, and free-text notes.
///
/// CloudKit constraints: every property must be optional or have a
/// default value, and no `@Attribute(.unique)` on synced models.
@Model
final class LoggedFlight {
var id: UUID = UUID()
var loggedAt: Date = Date()
// MARK: Identity
var flightDate: Date = Date()
var carrierICAO: String? // "SWA"
var carrierIATA: String? // "WN"
var flightNumber: String? // "7"
// MARK: Route IATA codes are the canonical key into our airport DB
var departureIATA: String = ""
var arrivalIATA: String = ""
var scheduledDeparture: Date?
var scheduledArrival: Date?
var actualDeparture: Date?
var actualArrival: Date?
// MARK: Aircraft
var aircraftType: String? // "B738"
var registration: String? // "N281WN" also keys into AirframeMetadata
// MARK: Personal
var notes: String?
/// Origin of this record. Used for analytics / debugging only.
/// Values: "live-tap" | "manual" | "calendar" | "wallet" | "mail-share"
var source: String = "manual"
init(
id: UUID = UUID(),
loggedAt: Date = Date(),
flightDate: Date = Date(),
carrierICAO: String? = nil,
carrierIATA: String? = nil,
flightNumber: String? = nil,
departureIATA: String = "",
arrivalIATA: String = "",
scheduledDeparture: Date? = nil,
scheduledArrival: Date? = nil,
actualDeparture: Date? = nil,
actualArrival: Date? = nil,
aircraftType: String? = nil,
registration: String? = nil,
notes: String? = nil,
source: String = "manual"
) {
self.id = id
self.loggedAt = loggedAt
self.flightDate = flightDate
self.carrierICAO = carrierICAO
self.carrierIATA = carrierIATA
self.flightNumber = flightNumber
self.departureIATA = departureIATA
self.arrivalIATA = arrivalIATA
self.scheduledDeparture = scheduledDeparture
self.scheduledArrival = scheduledArrival
self.actualDeparture = actualDeparture
self.actualArrival = actualArrival
self.aircraftType = aircraftType
self.registration = registration
self.notes = notes
self.source = source
}
/// IATA-style flight label, e.g. "WN7" or "SWA7" if IATA is missing.
var flightLabel: String {
let prefix = carrierIATA ?? carrierICAO ?? ""
let number = flightNumber ?? ""
if prefix.isEmpty && number.isEmpty { return "" }
return "\(prefix)\(number)"
}
}
@@ -0,0 +1,119 @@
import Foundation
import EventKit
/// Scans the user's iOS calendars for events that look like flights and
/// returns parsed candidates. The user confirms each one in
/// `CalendarImportView` before anything lands in the log.
///
/// Detection is pattern-based on the event title we look for any
/// `[A-Z]{2,3}\s*\d{1,4}` substring like "WN 7" / "SWA7" / "AA2178".
/// We also try to pull a route hint ("DFW HOU") if the title or
/// notes carry one.
@MainActor
final class CalendarFlightImporter {
let store: EKEventStore
init(store: EKEventStore = EKEventStore()) {
self.store = store
}
struct Candidate: Identifiable {
let id = UUID()
let event: EKEvent
let carrierIATA: String?
let flightNumber: String?
let departureIATA: String?
let arrivalIATA: String?
var flightDate: Date { event.startDate }
var flightLabel: String { "\(carrierIATA ?? "?")\(flightNumber ?? "?")" }
}
/// Request calendar access via the modern API.
func requestAccess() async -> Bool {
if #available(iOS 17.0, *) {
do {
return try await store.requestFullAccessToEvents()
} catch {
return false
}
} else {
return await withCheckedContinuation { cont in
store.requestAccess(to: .event) { granted, _ in
cont.resume(returning: granted)
}
}
}
}
/// Scan all calendars between `from` and `to` for flight-shaped events.
/// Default range: last 5 years through next 30 days, which is enough
/// to catch most users' existing history without going overboard.
func scan(
from: Date = Calendar.current.date(byAdding: .year, value: -5, to: Date()) ?? Date(),
to: Date = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
) -> [Candidate] {
// EventKit caps the search window break it into yearly chunks
// so we cover the full lookback even when the user has 5+ years
// of calendar history.
var out: [Candidate] = []
var cursor = from
let chunk: TimeInterval = 365 * 24 * 60 * 60
while cursor < to {
let end = min(cursor.addingTimeInterval(chunk), to)
let predicate = store.predicateForEvents(withStart: cursor, end: end, calendars: nil)
let events = store.events(matching: predicate)
for e in events {
if let c = parse(e) { out.append(c) }
}
cursor = end
}
return out
}
private func parse(_ event: EKEvent) -> Candidate? {
let haystack = [event.title, event.notes, event.location]
.compactMap { $0 }
.joined(separator: " ")
guard let match = matchFlightCode(in: haystack) else { return nil }
let route = matchRoute(in: haystack)
return Candidate(
event: event,
carrierIATA: match.carrier,
flightNumber: match.number,
departureIATA: route?.from,
arrivalIATA: route?.to
)
}
/// Find the first flight-code-shaped substring. Allows a single
/// space between letters and digits (e.g. "WN 7", "AA 2178").
private func matchFlightCode(in s: String) -> (carrier: String, number: String)? {
let pattern = "([A-Z]{2,3})\\s*([0-9]{1,4})"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(s.startIndex..., in: s)
for m in regex.matches(in: s, range: range) where m.numberOfRanges == 3 {
guard let cRange = Range(m.range(at: 1), in: s),
let nRange = Range(m.range(at: 2), in: s)
else { continue }
let carrier = String(s[cRange])
// Filter false positives: skip common 2-letter codes that
// aren't airlines but show up a lot in event titles.
let denylist: Set<String> = ["AM", "PM", "ET", "PT", "CT", "MT", "US", "UK", "AS"]
if denylist.contains(carrier) { continue }
return (carrier, String(s[nRange]))
}
return nil
}
/// Find a "XXX YYY" or "XXX-YYY" route hint.
private func matchRoute(in s: String) -> (from: String, to: String)? {
let pattern = "([A-Z]{3})\\s*(?:[-→>]|to)\\s*([A-Z]{3})"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(s.startIndex..., in: s)
guard let m = regex.firstMatch(in: s, range: range), m.numberOfRanges == 3,
let fRange = Range(m.range(at: 1), in: s),
let tRange = Range(m.range(at: 2), in: s)
else { return nil }
return (String(s[fRange]), String(s[tRange]))
}
}
+129
View File
@@ -0,0 +1,129 @@
import Foundation
import SwiftData
import CoreLocation
/// Convenience wrapper around the SwiftData ModelContext for
/// LoggedFlight CRUD + airframe metadata caching. View code talks to
/// this rather than poking ModelContext directly so we have a single
/// place to enforce dedupe rules, derive computed fields, etc.
@MainActor
final class FlightHistoryStore {
let context: ModelContext
private let airportDatabase: AirportDatabase
init(context: ModelContext, airportDatabase: AirportDatabase) {
self.context = context
self.airportDatabase = airportDatabase
}
// MARK: - LoggedFlight CRUD
/// Save a new flight. No dedupe logic here callers (importers)
/// own that. Direct user adds always create a fresh record.
@discardableResult
func save(_ flight: LoggedFlight) -> LoggedFlight {
context.insert(flight)
try? context.save()
return flight
}
func delete(_ flight: LoggedFlight) {
context.delete(flight)
try? context.save()
}
/// Returns true if a flight with the same date + flight number +
/// route already exists. Used by importers to skip dupes.
func exists(flightDate: Date, flightLabel: String, departureIATA: String, arrivalIATA: String) -> Bool {
let day = Calendar.current.startOfDay(for: flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let predicate = #Predicate<LoggedFlight> { f in
f.flightDate >= day && f.flightDate < next
&& f.departureIATA == departureIATA
&& f.arrivalIATA == arrivalIATA
}
let descriptor = FetchDescriptor<LoggedFlight>(predicate: predicate)
let matches = (try? context.fetch(descriptor)) ?? []
return matches.contains { f in
f.flightLabel.uppercased() == flightLabel.uppercased()
}
}
func allFlights() -> [LoggedFlight] {
let descriptor = FetchDescriptor<LoggedFlight>(
sortBy: [SortDescriptor(\.flightDate, order: .reverse)]
)
return (try? context.fetch(descriptor)) ?? []
}
// MARK: - AirframeMetadata cache
func airframe(for registration: String) -> AirframeMetadata? {
let reg = registration.uppercased()
let descriptor = FetchDescriptor<AirframeMetadata>(
predicate: #Predicate { $0.registration == reg }
)
return (try? context.fetch(descriptor))?.first
}
@discardableResult
func upsertAirframe(
registration: String,
firstFlightDate: Date? = nil,
deliveryDate: Date? = nil
) -> AirframeMetadata {
let reg = registration.uppercased()
if let existing = airframe(for: reg) {
if let firstFlightDate { existing.firstFlightDate = firstFlightDate }
if let deliveryDate { existing.deliveryDate = deliveryDate }
existing.scrapedAt = Date()
try? context.save()
return existing
}
let m = AirframeMetadata(
registration: reg,
firstFlightDate: firstFlightDate,
deliveryDate: deliveryDate,
scrapedAt: Date()
)
context.insert(m)
try? context.save()
return m
}
/// How many previously-logged flights have used this same tail
/// number. Used for the "2nd time on this plane" callout.
func repeatCount(for registration: String?, before flightDate: Date) -> Int {
guard let registration, !registration.isEmpty else { return 0 }
let reg = registration.uppercased()
let descriptor = FetchDescriptor<LoggedFlight>(
predicate: #Predicate { f in
f.registration == reg && f.flightDate < flightDate
}
)
return (try? context.fetch(descriptor))?.count ?? 0
}
// MARK: - Distance / duration helpers
/// Great-circle distance in statute miles between this flight's
/// dep and arr airports.
func distanceMiles(for flight: LoggedFlight) -> Int? {
guard let dep = airportDatabase.airport(byIATA: flight.departureIATA),
let arr = airportDatabase.airport(byIATA: flight.arrivalIATA)
else { return nil }
let depLoc = CLLocation(latitude: dep.coordinate.latitude, longitude: dep.coordinate.longitude)
let arrLoc = CLLocation(latitude: arr.coordinate.latitude, longitude: arr.coordinate.longitude)
let meters = depLoc.distance(from: arrLoc)
return Int(meters / 1609.34)
}
/// Duration in minutes prefers actual times, falls back to
/// scheduled, returns nil if neither is set.
func durationMinutes(for flight: LoggedFlight) -> Int? {
let dep = flight.actualDeparture ?? flight.scheduledDeparture
let arr = flight.actualArrival ?? flight.scheduledArrival
guard let dep, let arr, arr > dep else { return nil }
return Int(arr.timeIntervalSince(dep) / 60)
}
}
+136
View File
@@ -0,0 +1,136 @@
import Foundation
import CoreLocation
/// Computes totals + narrative stats over the user's flight history.
/// Pure derivation no side effects, no I/O. Built once per view body
/// pass over a flights snapshot.
@MainActor
struct StatsEngine {
let flights: [LoggedFlight]
let store: FlightHistoryStore
let database: AirportDatabase
init(store: FlightHistoryStore, database: AirportDatabase, flights: [LoggedFlight]) {
self.store = store
self.database = database
self.flights = flights
}
// MARK: - Totals
var totalFlights: Int { flights.count }
var totalMiles: Int {
flights.reduce(0) { acc, f in acc + (store.distanceMiles(for: f) ?? 0) }
}
var totalMinutes: Int {
flights.reduce(0) { acc, f in
// Prefer logged duration; fall back to estimated 7 min per 100 mi.
if let d = store.durationMinutes(for: f) { return acc + d }
if let mi = store.distanceMiles(for: f) { return acc + Int(Double(mi) / 100.0 * 7.0) }
return acc
}
}
var totalHours: Int { totalMinutes / 60 }
var uniqueAirports: Int {
Set(flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }
.filter { !$0.isEmpty }).count
}
var uniqueAirlines: Int {
Set(flights.compactMap { $0.carrierICAO ?? $0.carrierIATA }).count
}
var uniqueAircraftTypes: Int {
Set(flights.compactMap { $0.aircraftType }).count
}
var uniqueCountries: Int {
Set(flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }
.compactMap { database.airport(byIATA: $0)?.country }).count
}
// MARK: - Compact display
var shortDistance: String {
let n = totalMiles
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
if n >= 10_000 { return String(format: "%.0fk", Double(n) / 1_000) }
return numberString(n)
}
var shortDuration: String {
if totalHours >= 1000 { return String(format: "%.0fk", Double(totalHours) / 1_000) }
return "\(totalHours)"
}
// MARK: - Narrative
/// Most-flown carrier ICAO.
var topAirline: (icao: String, count: Int)? {
let counts = Dictionary(grouping: flights.compactMap { $0.carrierICAO ?? $0.carrierIATA }) { $0 }
.mapValues(\.count)
return counts.max(by: { $0.value < $1.value }).map { ($0.key, $0.value) }
}
/// Most-flown route (dep + arr, ignoring direction).
var topRoute: (label: String, count: Int)? {
let pairs = flights.map { f in [f.departureIATA, f.arrivalIATA].sorted().joined(separator: "") }
let counts = Dictionary(grouping: pairs) { $0 }.mapValues(\.count)
return counts.max(by: { $0.value < $1.value }).map { ($0.key, $0.value) }
}
/// Most-visited airport (counts each endpoint independently).
var topAirport: (iata: String, count: Int)? {
let codes = flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }.filter { !$0.isEmpty }
let counts = Dictionary(grouping: codes) { $0 }.mapValues(\.count)
return counts.max(by: { $0.value < $1.value }).map { ($0.key, $0.value) }
}
/// Tail numbers we've flown more than once.
var repeatedTails: [(reg: String, count: Int)] {
let regs = flights.compactMap { $0.registration }
let counts = Dictionary(grouping: regs) { $0 }.mapValues(\.count)
return counts.filter { $0.value > 1 }
.map { ($0.key, $0.value) }
.sorted { $0.count > $1.count }
}
/// Longest single flight by distance.
var longestFlight: LoggedFlight? {
flights.max { (store.distanceMiles(for: $0) ?? 0) < (store.distanceMiles(for: $1) ?? 0) }
}
/// Shortest single flight by distance.
var shortestFlight: LoggedFlight? {
flights
.filter { (store.distanceMiles(for: $0) ?? 0) > 0 }
.min { (store.distanceMiles(for: $0) ?? 0) < (store.distanceMiles(for: $1) ?? 0) }
}
/// Flights bucketed by year, most recent first.
var byYear: [(year: Int, flights: [LoggedFlight])] {
let cal = Calendar.current
let grouped = Dictionary(grouping: flights) { cal.component(.year, from: $0.flightDate) }
return grouped
.map { (year: $0.key, flights: $0.value) }
.sorted { $0.year > $1.year }
}
/// Flights for one calendar year.
func flights(for year: Int) -> [LoggedFlight] {
let cal = Calendar.current
return flights.filter { cal.component(.year, from: $0.flightDate) == year }
}
// MARK: - Helpers
private func numberString(_ n: Int) -> String {
let f = NumberFormatter()
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
}
+140
View File
@@ -0,0 +1,140 @@
import Foundation
import PassKit
import Combine
/// Watches Apple Wallet's PKPassLibrary for newly-added boarding passes
/// and emits parsed flight data. The app can subscribe and prompt to
/// log when one shows up.
///
/// PKPassLibrary read access doesn't require the
/// `pass-type-identifiers` entitlement (which is only needed to write
/// passes you own). Listening to library-change notifications and
/// reading metadata of any boarding pass works on a default app.
@MainActor
final class WalletPassObserver: ObservableObject {
static let shared = WalletPassObserver()
@Published private(set) var pendingPass: ParsedPass?
struct ParsedPass: Hashable {
let flightDate: Date
let carrierIATA: String?
let flightNumber: String?
let departureIATA: String?
let arrivalIATA: String?
let seat: String?
let serialNumber: String
}
private let library: PKPassLibrary
private var token: NSObjectProtocol?
private var knownSerials: Set<String> = []
private init() {
self.library = PKPassLibrary()
// Seed with currently-installed passes so we don't spam on
// first launch we only want to prompt for *new* passes.
for p in library.passes() {
knownSerials.insert(p.serialNumber)
}
startObserving()
}
private func startObserving() {
// PKPassLibraryDidChangeNotification is posted whenever the
// user adds/removes a pass. We diff the library against our
// seen set to find the new one.
// The PKPassLibrary notification name isn't exposed as a typed
// constant on the class fall back to the raw string the
// framework posts.
token = NotificationCenter.default.addObserver(
forName: Notification.Name("PKPassLibraryDidChangeNotification"),
object: library,
queue: .main
) { [weak self] note in
Task { @MainActor [weak self] in
self?.diff()
}
}
}
private func diff() {
let current = library.passes()
for pass in current {
if knownSerials.contains(pass.serialNumber) { continue }
knownSerials.insert(pass.serialNumber)
if let parsed = Self.parse(pass) {
pendingPass = parsed
return
}
}
}
/// Clear the published pending pass once the UI has consumed it.
func clearPending() {
pendingPass = nil
}
// MARK: - Parsing
//
// A pkpass JSON manifests includes a "boardingPass" object with
// `transitType: PKTransitTypeAir`, then a soup of structured
// fields. The standard names used by most airlines:
// primaryFields[0].key = "depart" or "origin"
// primaryFields[1].key = "destination"
// auxiliaryFields[] includes seat / gate / flight#
// We don't have direct access to the JSON only to PKPass's
// typed API (`localizedValue(forFieldKey:)`).
private static func parse(_ pass: PKPass) -> ParsedPass? {
// PKPass doesn't expose pass-style (boarding/coupon/event/etc.)
// via a typed property we infer it from the presence of
// boarding-pass-style field keys below.
// Common field keys across airlines.
let originKey = ["origin", "depart", "from", "departing"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let destKey = ["destination", "arrive", "to", "arriving"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let flightKey = ["flight", "flightNumber", "flightNo"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let seatKey = ["seat", "seatNumber"]
.first { pass.localizedValue(forFieldKey: $0) != nil }
let origin = originKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
let dest = destKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
let flight = flightKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
let seat = seatKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
// Try to split the flight into carrier + number. Boarding pass
// values are typically formatted like "WN 7" or "AA2178".
var carrier: String?
var number: String?
if let flight, let m = flight.range(of: "([A-Z]{2,3})\\s*([0-9]{1,4})", options: .regularExpression) {
let s = String(flight[m])
let scanner = Scanner(string: s)
scanner.charactersToBeSkipped = .whitespaces
var letters: NSString?
var digits: NSString?
scanner.scanCharacters(from: .uppercaseLetters, into: &letters)
scanner.scanCharacters(from: .decimalDigits, into: &digits)
carrier = letters as String?
number = digits as String?
}
// The relevant date is pass.relevantDate (when the pass should
// appear on the lock screen). For a boarding pass, that's
// typically the departure time.
let flightDate = pass.relevantDate ?? Date()
return ParsedPass(
flightDate: flightDate,
carrierIATA: carrier,
flightNumber: number,
departureIATA: origin?.uppercased(),
arrivalIATA: dest?.uppercased(),
seat: seat,
serialNumber: pass.serialNumber
)
}
}
+182
View File
@@ -0,0 +1,182 @@
import SwiftUI
/// Shared add-flight form. Used by:
/// - The "+" toolbar on the History tab (no prefill full manual entry)
/// - The "Add to my flights" button on a live aircraft sheet (prefilled
/// from FR24 enrichment)
/// - Calendar import (prefilled from a calendar event regex match)
/// - Mail Share Extension (prefilled from a parsed email)
///
/// The user can always edit any field. The "Look up" action hits
/// route-explorer's schedule endpoint to fill departure/arrival/times
/// given a carrier + flight # + date.
struct AddFlightView: View {
let routeExplorer: RouteExplorerClient
let database: AirportDatabase
let store: FlightHistoryStore
let prefill: Prefill?
@Environment(\.dismiss) private var dismiss
@State private var flightDate: Date = Date()
@State private var carrierIATA: String = ""
@State private var flightNumber: String = ""
@State private var departureIATA: String = ""
@State private var arrivalIATA: String = ""
@State private var scheduledDeparture: Date?
@State private var scheduledArrival: Date?
@State private var aircraftType: String = ""
@State private var registration: String = ""
@State private var notes: String = ""
@State private var isLooking = false
@State private var lookupError: String?
struct Prefill {
var flightDate: Date
var carrierICAO: String?
var carrierIATA: String?
var flightNumber: String?
var departureIATA: String?
var arrivalIATA: String?
var scheduledDeparture: Date?
var scheduledArrival: Date?
var aircraftType: String?
var registration: String?
var source: String
}
var body: some View {
NavigationStack {
Form {
Section("Flight") {
DatePicker("Date", selection: $flightDate, displayedComponents: .date)
HStack {
TextField("Airline (e.g. WN)", text: $carrierIATA)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
.frame(width: 100)
TextField("Flight #", text: $flightNumber)
.keyboardType(.numberPad)
Button(action: { Task { await runLookup() } }) {
if isLooking { ProgressView() }
else { Image(systemName: "magnifyingglass") }
}
.disabled(carrierIATA.isEmpty || flightNumber.isEmpty || isLooking)
}
if let lookupError {
Text(lookupError)
.font(.caption)
.foregroundStyle(.red)
}
}
Section("Route") {
TextField("From (IATA)", text: $departureIATA)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
TextField("To (IATA)", text: $arrivalIATA)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
if let dep = Binding($scheduledDeparture) {
DatePicker("Departure", selection: dep)
} else {
Button("Add scheduled departure") { scheduledDeparture = flightDate }
}
if let arr = Binding($scheduledArrival) {
DatePicker("Arrival", selection: arr)
} else {
Button("Add scheduled arrival") { scheduledArrival = flightDate }
}
}
Section("Aircraft") {
TextField("Type (e.g. B738)", text: $aircraftType)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
TextField("Tail # (e.g. N281WN)", text: $registration)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
}
Section("Notes") {
TextField("Optional", text: $notes, axis: .vertical)
.lineLimit(3...8)
}
}
.navigationTitle("Add flight")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(!isValid)
}
}
.onAppear { applyPrefill() }
}
}
private var isValid: Bool {
!departureIATA.isEmpty && !arrivalIATA.isEmpty
&& departureIATA.count >= 3 && arrivalIATA.count >= 3
}
private func applyPrefill() {
guard let p = prefill else { return }
flightDate = p.flightDate
carrierIATA = p.carrierIATA ?? ""
flightNumber = p.flightNumber ?? ""
departureIATA = (p.departureIATA ?? "").uppercased()
arrivalIATA = (p.arrivalIATA ?? "").uppercased()
scheduledDeparture = p.scheduledDeparture
scheduledArrival = p.scheduledArrival
aircraftType = (p.aircraftType ?? "").uppercased()
registration = (p.registration ?? "").uppercased()
}
private func runLookup() async {
isLooking = true
defer { isLooking = false }
lookupError = nil
guard let num = Int(flightNumber.trimmingCharacters(in: .whitespaces)) else {
lookupError = "Flight number must be numeric"
return
}
let day = Calendar.current.startOfDay(for: flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let results = await routeExplorer.searchSchedule(
carrierCode: carrierIATA.uppercased(),
flightNumber: num,
startDate: day,
endDate: next
)
guard let r = results.first else {
lookupError = "No schedule match for \(carrierIATA)\(flightNumber) on this date"
return
}
departureIATA = r.departure.airportIata
arrivalIATA = r.arrival.airportIata
scheduledDeparture = r.departure.dateTime
scheduledArrival = r.arrival.dateTime
}
private func save() {
let carrierICAO = AircraftRegistry.shared.lookup(iata: carrierIATA)?.icao
let f = LoggedFlight(
flightDate: flightDate,
carrierICAO: carrierICAO,
carrierIATA: carrierIATA.isEmpty ? nil : carrierIATA.uppercased(),
flightNumber: flightNumber.isEmpty ? nil : flightNumber,
departureIATA: departureIATA.uppercased(),
arrivalIATA: arrivalIATA.uppercased(),
scheduledDeparture: scheduledDeparture,
scheduledArrival: scheduledArrival,
aircraftType: aircraftType.isEmpty ? nil : aircraftType.uppercased(),
registration: registration.isEmpty ? nil : registration.uppercased(),
notes: notes.isEmpty ? nil : notes,
source: prefill?.source ?? "manual"
)
store.save(f)
dismiss()
}
}
+199
View File
@@ -0,0 +1,199 @@
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)
}
}
+408
View File
@@ -0,0 +1,408 @@
import SwiftUI
import MapKit
import CoreLocation
/// Single-flight detail screen. Layout follows the live detail sheet
/// pattern (title route photo map aircraft) but adds notes
/// and a delete button. Pulls a track replay from OpenSky for flights
/// flown in the last ~7 days; everything older falls back to a clean
/// great-circle arc.
struct HistoryDetailView: View {
let flight: LoggedFlight
let store: FlightHistoryStore
let database: AirportDatabase
let openSky: OpenSkyClient
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var photo: AircraftPhotoService.Photo?
@State private var track: AircraftTrack?
@State private var editedNotes: String = ""
@State private var showDeleteConfirm = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
routeCard
photoBanner
.padding(.horizontal, -16)
if let cred = photo?.photographer {
photoCredit(name: cred, link: photo?.detailLink)
}
mapSection
aircraftCard
notesSection
deleteButton
}
.padding(16)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle(flight.flightLabel)
.navigationBarTitleDisplayMode(.inline)
.task {
editedNotes = flight.notes ?? ""
if let reg = flight.registration {
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "")
}
await loadTrackIfRecent()
}
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
Button("Delete", role: .destructive) {
store.delete(flight)
dismiss()
}
Button("Cancel", role: .cancel) {}
}
}
// MARK: - Header
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
if let logo = airlineLogoURL {
AsyncImage(url: logo) { phase in
switch phase {
case .success(let img): img.resizable().scaledToFit()
default: RoundedRectangle(cornerRadius: 8).fill(FlightTheme.accent.opacity(0.2))
}
}
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Text(flight.flightLabel)
.font(.title.weight(.bold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text(longDate(flight.flightDate))
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
Text(airlineName)
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
}
// MARK: - Route card
private var routeCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text("ROUTE")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
HStack(spacing: 16) {
endpoint(iata: flight.departureIATA, label: "Departed", time: flight.actualDeparture ?? flight.scheduledDeparture)
Image(systemName: "airplane")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.rotationEffect(.degrees(-45))
endpoint(iata: flight.arrivalIATA, label: "Arrived", time: flight.actualArrival ?? flight.scheduledArrival)
}
if let mi = store.distanceMiles(for: flight) {
Text("\(numberString(mi)) miles · \(durationDisplay)")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
}
.flightCard()
}
private func endpoint(iata: String, label: String, time: Date?) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(iata.isEmpty ? "" : iata)
.font(FlightTheme.airportCode(28))
.foregroundStyle(FlightTheme.textPrimary)
if let m = database.airport(byIATA: iata) {
Text(m.name)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
if let time {
Text(shortDateTime(time))
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Photo
@ViewBuilder
private var photoBanner: some View {
if let photo {
AsyncImage(url: photo.largeURL) { phase in
switch phase {
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
default:
Rectangle().fill(FlightTheme.cardBackground)
}
}
.frame(maxWidth: .infinity)
.frame(height: 200)
.clipped()
}
}
private func photoCredit(name: String, link: URL?) -> some View {
HStack(spacing: 4) {
Image(systemName: "camera.fill").font(.caption2)
Text("Photo by \(name) · planespotters.net")
.font(.caption2)
}
.foregroundStyle(FlightTheme.textTertiary)
.contentShape(Rectangle())
.onTapGesture { if let link { openURL(link) } }
}
// MARK: - Map
@ViewBuilder
private var mapSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text(track == nil ? "ROUTE MAP" : "FLOWN PATH")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
FlightRouteMap(
departureIATA: flight.departureIATA,
arrivalIATA: flight.arrivalIATA,
track: track,
database: database
)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
private func loadTrackIfRecent() async {
// OpenSky's anonymous track endpoint goes back roughly 7 days
// before they trim history. Older logs get the great-circle
// fallback drawn by FlightRouteMap.
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
guard ageDays < 7, let icao24 = guessICAO24() else { return }
track = await openSky.track(icao24: icao24)
}
/// We don't store icao24 on the LoggedFlight (we store registration
/// instead) but for track replay we need icao24. Future work: pull
/// regicao24 mapping from a fresh OpenSky lookup. For now, only the
/// most-recently-logged airframe gets a replay attempt.
private func guessICAO24() -> String? {
// TODO: tie this to a regicao24 resolution. For v1 the
// track replay only fires when icao24 is in notes or we
// resolve via aircraft DB.
return nil
}
// MARK: - Aircraft card
private var aircraftCard: some View {
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
let airframe = flight.registration.flatMap(store.airframe(for:))
let ageYears = airframe?.firstFlightDate.map { years(since: $0) }
return VStack(alignment: .leading, spacing: 8) {
Text("AIRCRAFT")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
VStack(spacing: 0) {
HStack(spacing: 0) {
cell(label: "Type", value: flight.aircraftType ?? "")
cell(label: "Tail", value: flight.registration ?? "")
}
if ageYears != nil || repeats > 0 {
Divider()
HStack(spacing: 0) {
if let yrs = ageYears {
cell(label: "Age", value: "\(yrs)y")
} else {
cell(label: "Age", value: "")
}
cell(
label: "On this airframe",
value: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time"
)
}
}
}
.flightCard(padding: 0)
}
}
private func cell(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(value)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
}
// MARK: - Notes
private var notesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("NOTES")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
TextEditor(text: $editedNotes)
.frame(minHeight: 80)
.padding(8)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
.onChange(of: editedNotes) { _, newValue in
flight.notes = newValue.isEmpty ? nil : newValue
}
}
}
// MARK: - Delete
private var deleteButton: some View {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete flight")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.background(FlightTheme.cancelled.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(FlightTheme.cancelled)
.padding(.top, 8)
}
// MARK: - Helpers
private var airlineEntry: AircraftRegistry.Entry? {
AircraftRegistry.shared.lookup(icao: flight.carrierICAO)
?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)
}
private var airlineLogoURL: URL? { airlineEntry?.logoURL }
private var airlineName: String {
airlineEntry?.name ?? flight.carrierICAO ?? flight.carrierIATA ?? "Unknown"
}
private var durationDisplay: String {
guard let min = store.durationMinutes(for: flight) else { return "" }
let h = min / 60
let m = min % 60
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
}
private func numberString(_ n: Int) -> String {
let f = NumberFormatter()
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
private func years(since: Date) -> Int {
Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0
}
private func ordinalSuffix(_ n: Int) -> String {
let r = n % 100
if r >= 11 && r <= 13 { return "th" }
switch n % 10 {
case 1: return "st"
case 2: return "nd"
case 3: return "rd"
default: return "th"
}
}
private func longDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f.string(from: d)
}
private func shortDateTime(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, HH:mm"
return f.string(from: d)
}
}
/// Map view used by the history detail. Draws the actual flown track
/// when supplied; otherwise a great-circle arc between dep + arr.
private struct FlightRouteMap: View {
let departureIATA: String
let arrivalIATA: String
let track: AircraftTrack?
let database: AirportDatabase
var body: some View {
Map {
if let dep = database.airport(byIATA: departureIATA) {
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
.tint(FlightTheme.onTime)
}
if let arr = database.airport(byIATA: arrivalIATA) {
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
.tint(FlightTheme.accent)
}
if let track {
let coords = track.path.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
MapPolyline(coordinates: coords)
.stroke(FlightTheme.accent, lineWidth: 3)
} else if let dep = database.airport(byIATA: departureIATA),
let arr = database.airport(byIATA: arrivalIATA) {
MapPolyline(coordinates: greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 64))
.stroke(FlightTheme.accent.opacity(0.6), style: StrokeStyle(lineWidth: 2, dash: [5, 4]))
}
}
}
/// Polyline samples along the great-circle path between two
/// coordinates. MapKit doesn't draw GC paths natively we
/// approximate with N straight segments along the GC route.
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
let lat1 = a.latitude * .pi / 180
let lon1 = a.longitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let lon2 = b.longitude * .pi / 180
let d = 2 * asin(sqrt(
pow(sin((lat2 - lat1) / 2), 2)
+ cos(lat1) * cos(lat2) * pow(sin((lon2 - lon1) / 2), 2)
))
if d == 0 { return [a, b] }
var out: [CLLocationCoordinate2D] = []
out.reserveCapacity(segments + 1)
for i in 0...segments {
let f = Double(i) / Double(segments)
let A = sin((1 - f) * d) / sin(d)
let B = sin(f * d) / sin(d)
let x = A * cos(lat1) * cos(lon1) + B * cos(lat2) * cos(lon2)
let y = A * cos(lat1) * sin(lon1) + B * cos(lat2) * sin(lon2)
let z = A * sin(lat1) + B * sin(lat2)
let lat = atan2(z, sqrt(x * x + y * y))
let lon = atan2(y, x)
out.append(CLLocationCoordinate2D(latitude: lat * 180 / .pi, longitude: lon * 180 / .pi))
}
return out
}
}
+122
View File
@@ -0,0 +1,122 @@
import SwiftUI
import MapKit
import CoreLocation
/// Lifetime route map every great-circle arc the user has flown,
/// with airport dots sized by visit count. Arcs animate in oldest
/// newest on first appear.
struct HistoryRouteMapView: View {
let flights: [LoggedFlight]
let database: AirportDatabase
@State private var revealCount: Int = 0
@State private var position: MapCameraPosition = .automatic
var body: some View {
let arcs = self.arcs
return Map(position: $position) {
// Airport dots sized by visit count
ForEach(airportItems, id: \.iata) { item in
Annotation(item.iata, coordinate: item.coord) {
Circle()
.fill(FlightTheme.accent)
.frame(width: dotSize(for: item.count), height: dotSize(for: item.count))
.overlay(Circle().stroke(.white, lineWidth: 1))
}
.annotationTitles(.hidden)
}
// Animated great-circle arcs
ForEach(arcs.prefix(revealCount)) { arc in
MapPolyline(coordinates: arc.coords)
.stroke(FlightTheme.accent.opacity(0.7), lineWidth: 1.5)
}
}
.mapStyle(.standard(elevation: .flat))
.navigationTitle("Routes")
.navigationBarTitleDisplayMode(.inline)
.task {
// Stagger reveal animation: ~30 ms per arc, capped so 200+
// flights still finish revealing in a reasonable time.
let step = max(0.012, min(0.04, 4.0 / Double(arcs.count + 1)))
for i in 0...arcs.count {
revealCount = i
try? await Task.sleep(nanoseconds: UInt64(step * 1_000_000_000))
}
}
}
// MARK: - Data prep
private struct Arc: Identifiable {
let id = UUID()
let coords: [CLLocationCoordinate2D]
}
private struct AirportItem: Hashable {
let iata: String
let coord: CLLocationCoordinate2D
let count: Int
static func == (lhs: AirportItem, rhs: AirportItem) -> Bool {
lhs.iata == rhs.iata
}
func hash(into hasher: inout Hasher) { hasher.combine(iata) }
}
private var arcs: [Arc] {
let sorted = flights.sorted { $0.flightDate < $1.flightDate }
return sorted.compactMap { f in
guard let dep = database.airport(byIATA: f.departureIATA),
let arr = database.airport(byIATA: f.arrivalIATA)
else { return nil }
let coords = Self.greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 48)
return Arc(coords: coords)
}
}
private var airportItems: [AirportItem] {
let codes = flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }.filter { !$0.isEmpty }
let counts = Dictionary(grouping: codes) { $0 }.mapValues(\.count)
return counts.compactMap { code, count in
guard let m = database.airport(byIATA: code) else { return nil }
return AirportItem(iata: code, coord: m.coordinate, count: count)
}
}
private func dotSize(for count: Int) -> CGFloat {
let v = log(Double(count) + 1) * 4 + 6
return CGFloat(min(18, max(6, v)))
}
/// Polyline samples along the great-circle path between two
/// coordinates. MapKit doesn't draw GC paths natively we
/// approximate with N straight segments along the GC route.
private static func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
let lat1 = a.latitude * .pi / 180
let lon1 = a.longitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let lon2 = b.longitude * .pi / 180
let d = 2 * asin(sqrt(
pow(sin((lat2 - lat1) / 2), 2)
+ cos(lat1) * cos(lat2) * pow(sin((lon2 - lon1) / 2), 2)
))
if d == 0 { return [a, b] }
var out: [CLLocationCoordinate2D] = []
out.reserveCapacity(segments + 1)
for i in 0...segments {
let f = Double(i) / Double(segments)
let A = sin((1 - f) * d) / sin(d)
let B = sin(f * d) / sin(d)
let x = A * cos(lat1) * cos(lon1) + B * cos(lat2) * cos(lon2)
let y = A * cos(lat1) * sin(lon1) + B * cos(lat2) * sin(lon2)
let z = A * sin(lat1) + B * sin(lat2)
let lat = atan2(z, sqrt(x * x + y * y))
let lon = atan2(y, x)
out.append(CLLocationCoordinate2D(latitude: lat * 180 / .pi, longitude: lon * 180 / .pi))
}
return out
}
}
+87
View File
@@ -0,0 +1,87 @@
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)
}
}
+179
View File
@@ -0,0 +1,179 @@
import SwiftUI
import SwiftData
/// Top-level history tab. Shows a totals strip + grouped-by-year list
/// of every flight the user has logged. Toolbar provides add paths
/// (manual, calendar scan) plus a button to drill into lifetime stats
/// and the lifetime route map.
struct HistoryView: View {
let database: AirportDatabase
let routeExplorer: RouteExplorerClient
let openSky: OpenSkyClient
@Environment(\.modelContext) private var modelContext
@Query(sort: \LoggedFlight.flightDate, order: .reverse)
private var flights: [LoggedFlight]
@State private var showingAdd = false
@State private var showingStats = false
@State private var showingMap = false
@State private var showingCalendarImport = false
@State private var showingYearInReview = false
var body: some View {
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
let stats = StatsEngine(store: store, database: database, flights: flights)
return List {
if !flights.isEmpty {
Section {
totalsStrip(stats: stats)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
ForEach(groupedByYear, id: \.year) { group in
Section(header: Text(String(group.year))) {
ForEach(group.flights) { flight in
NavigationLink {
HistoryDetailView(
flight: flight,
store: store,
database: database,
openSky: openSky
)
} label: {
HistoryRowView(flight: flight, database: database)
}
}
.onDelete { offsets in
for i in offsets { store.delete(group.flights[i]) }
}
}
}
if flights.isEmpty {
emptyState
}
}
.listStyle(.insetGrouped)
.navigationTitle("History")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button { showingAdd = true } label: {
Label("Add manually", systemImage: "plus")
}
Button { showingCalendarImport = true } label: {
Label("Scan Calendar", systemImage: "calendar")
}
Divider()
Button { showingStats = true } label: {
Label("Lifetime stats", systemImage: "chart.bar.fill")
}
Button { showingMap = true } label: {
Label("Route map", systemImage: "map.fill")
}
Button { showingYearInReview = true } label: {
Label("Year in Review", systemImage: "sparkles")
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title3)
}
}
}
.sheet(isPresented: $showingAdd) {
AddFlightView(
routeExplorer: routeExplorer,
database: database,
store: store,
prefill: nil
)
}
.sheet(isPresented: $showingStats) {
NavigationStack {
LifetimeStatsView(stats: stats)
}
}
.sheet(isPresented: $showingMap) {
NavigationStack {
HistoryRouteMapView(flights: flights, database: database)
}
}
.sheet(isPresented: $showingCalendarImport) {
CalendarImportView(
routeExplorer: routeExplorer,
database: database,
store: store
)
}
.sheet(isPresented: $showingYearInReview) {
YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date()))
}
}
// MARK: - Year grouping
private struct YearGroup {
let year: Int
let flights: [LoggedFlight]
}
private var groupedByYear: [YearGroup] {
let cal = Calendar.current
let grouped = Dictionary(grouping: flights) { cal.component(.year, from: $0.flightDate) }
return grouped
.map { YearGroup(year: $0.key, flights: $0.value) }
.sorted { $0.year > $1.year }
}
// MARK: - Totals strip
private func totalsStrip(stats: StatsEngine) -> some View {
HStack(spacing: 12) {
statTile(value: "\(stats.totalFlights)", label: "flights")
statTile(value: stats.shortDistance, label: "miles")
statTile(value: stats.shortDuration, label: "hours")
statTile(value: "\(stats.uniqueAirports)", label: "airports")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func statTile(value: String, label: String) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.textPrimary)
Text(label.uppercased())
.font(.caption2.weight(.semibold))
.tracking(0.6)
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "airplane.circle")
.font(.system(size: 48))
.foregroundStyle(FlightTheme.textTertiary)
Text("No flights logged yet")
.font(.headline)
.foregroundStyle(FlightTheme.textSecondary)
Text("Tap + to add a flight manually, scan your calendar, or tap an aircraft on the Live tab and add it from there.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(FlightTheme.textTertiary)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
.listRowBackground(Color.clear)
}
}
+130
View File
@@ -0,0 +1,130 @@
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))
}
}
}
}
+49
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SwiftData
import CoreLocation import CoreLocation
struct LiveFlightDetailSheet: View { struct LiveFlightDetailSheet: View {
@@ -10,6 +11,7 @@ struct LiveFlightDetailSheet: View {
@State private var recentFlights: [OpenSkyFlight] = [] @State private var recentFlights: [OpenSkyFlight] = []
@State private var isLoadingRoute = false @State private var isLoadingRoute = false
@State private var aircraftPhoto: AircraftPhotoService.Photo? @State private var aircraftPhoto: AircraftPhotoService.Photo?
@State private var showingAddToHistory = false
/// The resolved route for the current selection. Built from a cascade: /// The resolved route for the current selection. Built from a cascade:
/// scheduled flight (via route-explorer) OpenSky history trail-based /// scheduled flight (via route-explorer) OpenSky history trail-based
@@ -34,6 +36,7 @@ struct LiveFlightDetailSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@Environment(\.modelContext) private var modelContext
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -46,6 +49,8 @@ struct LiveFlightDetailSheet: View {
// user opened the sheet to see. // user opened the sheet to see.
routeSection routeSection
addToHistoryButton
// Aircraft photo follows the route. Negative // Aircraft photo follows the route. Negative
// horizontal padding lets the photo break out of the // horizontal padding lets the photo break out of the
// 16pt content padding to be full-bleed edge-to-edge. // 16pt content padding to be full-bleed edge-to-edge.
@@ -206,6 +211,50 @@ struct LiveFlightDetailSheet: View {
} }
} }
// MARK: - Add-to-history button
//
// Lives between the route card and the photo banner the natural
// "I'm on this plane right now, save it" spot. Opens AddFlightView
// pre-populated from FR24 enrichment so the user can confirm any
// detail before it lands in their log.
private var addToHistoryButton: some View {
Button {
showingAddToHistory = true
} label: {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill")
Text("Add to my flights")
.font(.subheadline.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.background(FlightTheme.accent, in: RoundedRectangle(cornerRadius: 12))
.foregroundStyle(.white)
.sheet(isPresented: $showingAddToHistory) {
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
AddFlightView(
routeExplorer: routeExplorer,
database: database,
store: store,
prefill: AddFlightView.Prefill(
flightDate: Date(),
carrierICAO: aircraft.airlineICAO,
carrierIATA: AircraftRegistry.shared.lookup(icao: aircraft.airlineICAO)?.iata,
flightNumber: aircraft.flightNumber,
departureIATA: aircraft.enrichment?.departureIATA,
arrivalIATA: aircraft.enrichment?.arrivalIATA,
scheduledDeparture: nil,
scheduledArrival: nil,
aircraftType: aircraft.typeCode,
registration: aircraft.enrichment?.registration,
source: "live-tap"
)
)
}
}
// MARK: - Photo banner // MARK: - Photo banner
// //
// Hero image at the very top of the sheet, sourced from planespotters. // Hero image at the very top of the sheet, sourced from planespotters.
+13 -1
View File
@@ -13,7 +13,7 @@ struct RootView: View {
@State private var selectedTab: Tab = .search @State private var selectedTab: Tab = .search
enum Tab: Hashable { case search, live } enum Tab: Hashable { case search, live, history }
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@@ -41,6 +41,18 @@ struct RootView: View {
Label("Live", systemImage: "antenna.radiowaves.left.and.right") Label("Live", systemImage: "antenna.radiowaves.left.and.right")
} }
.tag(Tab.live) .tag(Tab.live)
NavigationStack {
HistoryView(
database: database,
routeExplorer: routeExplorer,
openSky: openSky
)
}
.tabItem {
Label("History", systemImage: "book.closed")
}
.tag(Tab.history)
} }
.tint(FlightTheme.accent) .tint(FlightTheme.accent)
} }
+111
View File
@@ -0,0 +1,111 @@
import SwiftUI
/// Spotify-Wrapped-style year-in-review deck. Paged horizontal scroller
/// of cards, each highlighting one stat for the chosen year. Long-press
/// any card to copy a render-ready PNG.
struct YearInReviewView: View {
let stats: StatsEngine
let year: Int
@Environment(\.dismiss) private var dismiss
var body: some View {
let yearFlights = stats.flights(for: year)
let yearStats = StatsEngine(store: stats.store, database: stats.database, flights: yearFlights)
return NavigationStack {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
coverCard(year: year, flights: yearFlights.count)
statCard(headline: yearStats.shortDistance, subhead: "miles flown", footer: "\(yearFlights.count) flights")
statCard(headline: "\(yearStats.uniqueAirports)", subhead: "airports visited", footer: "across \(yearStats.uniqueCountries) countries")
statCard(headline: "\(yearStats.shortDuration) h", subhead: "in the air", footer: "\(yearStats.totalMinutes / 60) hours of cruise")
if let top = yearStats.topAirline {
statCard(
headline: AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao,
subhead: "Top airline",
footer: "\(top.count) flights"
)
}
if let route = yearStats.topRoute {
statCard(headline: route.label, subhead: "Top route", footer: "\(route.count) trips")
}
if let longest = yearStats.longestFlight {
statCard(
headline: "\(longest.departureIATA)\(longest.arrivalIATA)",
subhead: "Longest flight",
footer: "your endurance record"
)
}
}
.padding(.horizontal, 16)
}
.padding(.vertical, 24)
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Your \(year)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func coverCard(year: Int, flights: Int) -> some View {
VStack {
Spacer()
Text("\(year)")
.font(.system(size: 80, weight: .black).monospacedDigit())
.foregroundStyle(.white)
Text("in flight")
.font(.title3.weight(.semibold))
.foregroundStyle(.white.opacity(0.8))
Spacer()
Text("\(flights) flights logged")
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.6))
}
.frame(width: 320, height: 480)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accent.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 24)
)
.padding(.vertical, 8)
}
private func statCard(headline: String, subhead: String, footer: String) -> some View {
VStack(spacing: 12) {
Spacer()
Text(headline)
.font(.system(size: 56, weight: .bold).monospacedDigit())
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.5)
.foregroundStyle(.white)
.padding(.horizontal, 16)
Text(subhead)
.font(.title3.weight(.semibold))
.foregroundStyle(.white.opacity(0.85))
Spacer()
Text(footer)
.font(.caption)
.foregroundStyle(.white.opacity(0.65))
}
.frame(width: 320, height: 480)
.background(
LinearGradient(
colors: [FlightTheme.accent.opacity(0.85), FlightTheme.accent.opacity(0.45)],
startPoint: .top,
endPoint: .bottom
),
in: RoundedRectangle(cornerRadius: 24)
)
.padding(.vertical, 8)
}
}