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:
@@ -44,3 +44,6 @@ airlines/
|
|||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Playwright MCP scratch captures
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user