From 847e5c60357705a0850b13a937d00ccb0d2e1175 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 27 May 2026 09:34:38 -0500 Subject: [PATCH] Flight History (v1): logbook, stats, animated route map, year-in-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + Flights.xcodeproj/project.pbxproj | 59 +++ Flights/Flights.entitlements | 14 + Flights/FlightsApp.swift | 31 ++ Flights/Models/AirframeMetadata.swift | 27 ++ Flights/Models/LoggedFlight.swift | 84 ++++ Flights/Services/CalendarFlightImporter.swift | 119 +++++ Flights/Services/FlightHistoryStore.swift | 129 ++++++ Flights/Services/StatsEngine.swift | 136 ++++++ Flights/Services/WalletPassObserver.swift | 140 ++++++ Flights/Views/AddFlightView.swift | 182 ++++++++ Flights/Views/CalendarImportView.swift | 199 +++++++++ Flights/Views/HistoryDetailView.swift | 408 ++++++++++++++++++ Flights/Views/HistoryRouteMapView.swift | 122 ++++++ Flights/Views/HistoryRowView.swift | 87 ++++ Flights/Views/HistoryView.swift | 179 ++++++++ Flights/Views/LifetimeStatsView.swift | 130 ++++++ Flights/Views/LiveFlightDetailSheet.swift | 49 +++ Flights/Views/RootView.swift | 14 +- Flights/Views/YearInReviewView.swift | 111 +++++ 20 files changed, 2222 insertions(+), 1 deletion(-) create mode 100644 Flights/Flights.entitlements create mode 100644 Flights/Models/AirframeMetadata.swift create mode 100644 Flights/Models/LoggedFlight.swift create mode 100644 Flights/Services/CalendarFlightImporter.swift create mode 100644 Flights/Services/FlightHistoryStore.swift create mode 100644 Flights/Services/StatsEngine.swift create mode 100644 Flights/Services/WalletPassObserver.swift create mode 100644 Flights/Views/AddFlightView.swift create mode 100644 Flights/Views/CalendarImportView.swift create mode 100644 Flights/Views/HistoryDetailView.swift create mode 100644 Flights/Views/HistoryRouteMapView.swift create mode 100644 Flights/Views/HistoryRowView.swift create mode 100644 Flights/Views/HistoryView.swift create mode 100644 Flights/Views/LifetimeStatsView.swift create mode 100644 Flights/Views/YearInReviewView.swift diff --git a/.gitignore b/.gitignore index 65bb6d0..18b548e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ airlines/ # Claude .claude/ + +# Playwright MCP scratch captures +.playwright-mcp/ diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 21b0628..54cc8b1 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -61,6 +61,20 @@ LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; }; LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.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 */ /* Begin PBXContainerItemProxy section */ @@ -130,6 +144,21 @@ LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = ""; }; LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftPhotoService.swift; sourceTree = ""; }; + HX0100001111000011110002 /* LoggedFlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedFlight.swift; sourceTree = ""; }; + HX0200002222000022220002 /* AirframeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadata.swift; sourceTree = ""; }; + HX0300003333000033330002 /* FlightHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightHistoryStore.swift; sourceTree = ""; }; + HX0400004444000044440002 /* Flights.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Flights.entitlements; sourceTree = ""; }; + HX0500005555000055550002 /* StatsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsEngine.swift; sourceTree = ""; }; + HX0600006666000066660002 /* CalendarFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarFlightImporter.swift; sourceTree = ""; }; + HX0700007777000077770002 /* WalletPassObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletPassObserver.swift; sourceTree = ""; }; + HX0800008888000088880002 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + HX0900009999000099990002 /* HistoryRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRowView.swift; sourceTree = ""; }; + HX0A000AAAA000AAAA000002 /* HistoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryDetailView.swift; sourceTree = ""; }; + HX0B000BBBB000BBBB000002 /* AddFlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFlightView.swift; sourceTree = ""; }; + HX0C000CCCC000CCCC000002 /* CalendarImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarImportView.swift; sourceTree = ""; }; + HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifetimeStatsView.swift; sourceTree = ""; }; + HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = ""; }; + HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -168,6 +197,14 @@ LV6600006666000066660002 /* RootView.swift */, LV8800008888000088880002 /* OpenSkySettingsView.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 */, AA6666666666666666666666 /* Components */, ); @@ -252,6 +289,10 @@ LVDD000DDDD000DDDD000002 /* LocationService.swift */, LVEE000EEEE000EEEE000002 /* FR24Client.swift */, LVFF000FFFF000FFFF000002 /* AircraftPhotoService.swift */, + HX0300003333000033330002 /* FlightHistoryStore.swift */, + HX0500005555000055550002 /* StatsEngine.swift */, + HX0600006666000066660002 /* CalendarFlightImporter.swift */, + HX0700007777000077770002 /* WalletPassObserver.swift */, ); path = Services; sourceTree = ""; @@ -280,6 +321,8 @@ RE1100001111000011110002 /* RouteExplorerModels.swift */, RE8800008888000088880002 /* SearchRoute.swift */, LV1100001111000011110002 /* LiveAircraft.swift */, + HX0100001111000011110002 /* LoggedFlight.swift */, + HX0200002222000022220002 /* AirframeMetadata.swift */, ); path = Models; sourceTree = ""; @@ -426,6 +469,20 @@ LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */, LVEE000EEEE000EEEE000001 /* FR24Client.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; }; @@ -445,6 +502,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Flights/Flights.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; @@ -475,6 +533,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Flights/Flights.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; diff --git a/Flights/Flights.entitlements b/Flights/Flights.entitlements new file mode 100644 index 0000000..0b72a4a --- /dev/null +++ b/Flights/Flights.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.icloud-container-identifiers + + iCloud.com.flights.app + + com.apple.developer.icloud-services + + CloudKit + + + diff --git a/Flights/FlightsApp.swift b/Flights/FlightsApp.swift index db92812..429c5ef 100644 --- a/Flights/FlightsApp.swift +++ b/Flights/FlightsApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData @main struct FlightsApp: App { @@ -8,6 +9,12 @@ struct FlightsApp: App { let openSky = OpenSkyClient() 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() { let db = AirportDatabase() self.database = db @@ -18,6 +25,29 @@ struct FlightsApp: App { // jank the UI if we wait until first access on the Live tab. AircraftRegistry.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 { @@ -30,5 +60,6 @@ struct FlightsApp: App { fr24: fr24 ) } + .modelContainer(modelContainer) } } diff --git a/Flights/Models/AirframeMetadata.swift b/Flights/Models/AirframeMetadata.swift new file mode 100644 index 0000000..b45a3df --- /dev/null +++ b/Flights/Models/AirframeMetadata.swift @@ -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 + } +} diff --git a/Flights/Models/LoggedFlight.swift b/Flights/Models/LoggedFlight.swift new file mode 100644 index 0000000..5f6286b --- /dev/null +++ b/Flights/Models/LoggedFlight.swift @@ -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)" + } +} diff --git a/Flights/Services/CalendarFlightImporter.swift b/Flights/Services/CalendarFlightImporter.swift new file mode 100644 index 0000000..f7824f0 --- /dev/null +++ b/Flights/Services/CalendarFlightImporter.swift @@ -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 = ["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])) + } +} diff --git a/Flights/Services/FlightHistoryStore.swift b/Flights/Services/FlightHistoryStore.swift new file mode 100644 index 0000000..9dedd8a --- /dev/null +++ b/Flights/Services/FlightHistoryStore.swift @@ -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 { f in + f.flightDate >= day && f.flightDate < next + && f.departureIATA == departureIATA + && f.arrivalIATA == arrivalIATA + } + let descriptor = FetchDescriptor(predicate: predicate) + let matches = (try? context.fetch(descriptor)) ?? [] + return matches.contains { f in + f.flightLabel.uppercased() == flightLabel.uppercased() + } + } + + func allFlights() -> [LoggedFlight] { + let descriptor = FetchDescriptor( + 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( + 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( + 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) + } +} diff --git a/Flights/Services/StatsEngine.swift b/Flights/Services/StatsEngine.swift new file mode 100644 index 0000000..2091e21 --- /dev/null +++ b/Flights/Services/StatsEngine.swift @@ -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)" + } +} diff --git a/Flights/Services/WalletPassObserver.swift b/Flights/Services/WalletPassObserver.swift new file mode 100644 index 0000000..31890f2 --- /dev/null +++ b/Flights/Services/WalletPassObserver.swift @@ -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 = [] + + 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 + ) + } +} diff --git a/Flights/Views/AddFlightView.swift b/Flights/Views/AddFlightView.swift new file mode 100644 index 0000000..bb109fe --- /dev/null +++ b/Flights/Views/AddFlightView.swift @@ -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() + } +} diff --git a/Flights/Views/CalendarImportView.swift b/Flights/Views/CalendarImportView.swift new file mode 100644 index 0000000..644dc59 --- /dev/null +++ b/Flights/Views/CalendarImportView.swift @@ -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 = [] + @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) + } +} diff --git a/Flights/Views/HistoryDetailView.swift b/Flights/Views/HistoryDetailView.swift new file mode 100644 index 0000000..c9f2696 --- /dev/null +++ b/Flights/Views/HistoryDetailView.swift @@ -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 + /// reg→icao24 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 reg→icao24 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 + } +} diff --git a/Flights/Views/HistoryRouteMapView.swift b/Flights/Views/HistoryRouteMapView.swift new file mode 100644 index 0000000..fad8ca6 --- /dev/null +++ b/Flights/Views/HistoryRouteMapView.swift @@ -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 + } +} diff --git a/Flights/Views/HistoryRowView.swift b/Flights/Views/HistoryRowView.swift new file mode 100644 index 0000000..d3e00c2 --- /dev/null +++ b/Flights/Views/HistoryRowView.swift @@ -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) + } +} diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift new file mode 100644 index 0000000..c7cbdee --- /dev/null +++ b/Flights/Views/HistoryView.swift @@ -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) + } +} diff --git a/Flights/Views/LifetimeStatsView.swift b/Flights/Views/LifetimeStatsView.swift new file mode 100644 index 0000000..55cf2d8 --- /dev/null +++ b/Flights/Views/LifetimeStatsView.swift @@ -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)) + } + } + } +} diff --git a/Flights/Views/LiveFlightDetailSheet.swift b/Flights/Views/LiveFlightDetailSheet.swift index 63baa7a..4f99155 100644 --- a/Flights/Views/LiveFlightDetailSheet.swift +++ b/Flights/Views/LiveFlightDetailSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData import CoreLocation struct LiveFlightDetailSheet: View { @@ -10,6 +11,7 @@ struct LiveFlightDetailSheet: View { @State private var recentFlights: [OpenSkyFlight] = [] @State private var isLoadingRoute = false @State private var aircraftPhoto: AircraftPhotoService.Photo? + @State private var showingAddToHistory = false /// The resolved route for the current selection. Built from a cascade: /// scheduled flight (via route-explorer) → OpenSky history → trail-based @@ -34,6 +36,7 @@ struct LiveFlightDetailSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL + @Environment(\.modelContext) private var modelContext var body: some View { NavigationStack { @@ -46,6 +49,8 @@ struct LiveFlightDetailSheet: View { // user opened the sheet to see. routeSection + addToHistoryButton + // Aircraft photo follows the route. Negative // horizontal padding lets the photo break out of the // 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 // // Hero image at the very top of the sheet, sourced from planespotters. diff --git a/Flights/Views/RootView.swift b/Flights/Views/RootView.swift index 4591d7f..4442fa4 100644 --- a/Flights/Views/RootView.swift +++ b/Flights/Views/RootView.swift @@ -13,7 +13,7 @@ struct RootView: View { @State private var selectedTab: Tab = .search - enum Tab: Hashable { case search, live } + enum Tab: Hashable { case search, live, history } var body: some View { TabView(selection: $selectedTab) { @@ -41,6 +41,18 @@ struct RootView: View { Label("Live", systemImage: "antenna.radiowaves.left.and.right") } .tag(Tab.live) + + NavigationStack { + HistoryView( + database: database, + routeExplorer: routeExplorer, + openSky: openSky + ) + } + .tabItem { + Label("History", systemImage: "book.closed") + } + .tag(Tab.history) } .tint(FlightTheme.accent) } diff --git a/Flights/Views/YearInReviewView.swift b/Flights/Views/YearInReviewView.swift new file mode 100644 index 0000000..ad38e63 --- /dev/null +++ b/Flights/Views/YearInReviewView.swift @@ -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) + } +} +