diff --git a/Flights/Views/AircraftStatsView.swift b/Flights/Views/AircraftStatsView.swift index 5eff405..99200cd 100644 --- a/Flights/Views/AircraftStatsView.swift +++ b/Flights/Views/AircraftStatsView.swift @@ -14,9 +14,13 @@ struct AircraftStatsView: View { ScrollView { VStack(spacing: 16) { header - topStatsRow - typeListSection - mostFlownTailSection + if hasAnyAircraftData { + topStatsRow + typeListSection + mostFlownTailSection + } else { + emptyState + } Spacer(minLength: 60) } .padding(.horizontal, 16) @@ -31,6 +35,54 @@ struct AircraftStatsView: View { } } + /// True when at least one logged flight has an aircraft type OR + /// a registration we could surface. + private var hasAnyAircraftData: Bool { + !uniqueTypeCodes.isEmpty || mostFlownTail != nil + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "airplane.circle") + .font(.system(size: 56, weight: .heavy)) + .foregroundStyle(HistoryStyle.runwayOrange) + .padding(.top, 32) + Text("No aircraft data yet") + .font(.system(size: 20, weight: .heavy)) + .foregroundStyle(HistoryStyle.ink(scheme)) + VStack(spacing: 10) { + Text("Most flights in your log were imported from CSV — that export doesn't include the aircraft type or tail number.") + .multilineTextAlignment(.center) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + Text("To populate this screen:") + .font(.system(size: 13, weight: .heavy)) + .tracking(1.2) + .textCase(.uppercase) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + .padding(.top, 12) + VStack(alignment: .leading, spacing: 8) { + bullet("Tap an aircraft in the Live tab and use \"Add to my flights\"") + bullet("Open any logged flight and fill in Tail # / Type by hand") + bullet("New manual entries via the + menu let you set the type") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.system(size: 14)) + .padding(.horizontal, 24) + } + } + + private func bullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(HistoryStyle.runwayOrange) + .frame(width: 6, height: 6) + .padding(.top, 6) + Text(text) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + } + } + private var header: some View { VStack(spacing: 4) { Text("AIRCRAFT") diff --git a/Flights/Views/HistoryFilterSheet.swift b/Flights/Views/HistoryFilterSheet.swift index bd2fdf2..d610b24 100644 --- a/Flights/Views/HistoryFilterSheet.swift +++ b/Flights/Views/HistoryFilterSheet.swift @@ -28,6 +28,11 @@ struct HistoryFilterSheet: View { toggleRow(label: String(o.value), count: o.count, isOn: filters.years.contains(o.value)) { on in toggle(&filters.years, o.value, on) + // Years are the user's primary "scope this + // whole screen" filter — picking one is a + // commitment, so close the sheet so the + // animation can play immediately. + if on { dismiss() } } } } diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index b3edc1a..1ed62a4 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -95,9 +95,17 @@ struct HistoryView: View { } ToolbarItem(placement: .topBarTrailing) { Menu { - Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") } - Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") } - Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") } + Section("Add") { + Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") } + Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") } + Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") } + } + Section("Explore") { + Button { showingPassport = true } label: { Label("Passport", systemImage: "book.closed") } + Button { showingMap = true } label: { Label("Route map", systemImage: "map.fill") } + Button { showingAircraftStats = true } label: { Label("Aircraft stats", systemImage: "airplane.circle") } + Button { showingYearInReview = true } label: { Label("Year in Review", systemImage: "sparkles") } + } } label: { Image(systemName: "plus.circle.fill") .foregroundStyle(HistoryStyle.runwayOrange) @@ -133,7 +141,7 @@ struct HistoryView: View { CalendarImportView(routeExplorer: routeExplorer, database: database, store: store) } .sheet(isPresented: $showingCSVImport) { - ImportCSVView(store: store) + ImportCSVView(store: store, routeExplorer: routeExplorer) } .sheet(isPresented: $showingYearInReview) { YearInReviewView(stats: stats, year: selectedYear ?? Calendar.current.component(.year, from: Date())) diff --git a/Flights/Views/ImportCSVView.swift b/Flights/Views/ImportCSVView.swift index c84db1f..9e6180b 100644 --- a/Flights/Views/ImportCSVView.swift +++ b/Flights/Views/ImportCSVView.swift @@ -7,6 +7,7 @@ import UniformTypeIdentifiers /// a LoggedFlight. Dupes (same date + flight # + route) are skipped. struct ImportCSVView: View { let store: FlightHistoryStore + let routeExplorer: RouteExplorerClient @Environment(\.dismiss) private var dismiss @@ -17,6 +18,8 @@ struct ImportCSVView: View { @State private var skipped: Int = 0 @State private var errorText: String? @State private var importedCount: Int = 0 + @State private var enrichedCount: Int = 0 + @State private var enrichEnabled: Bool = true @State private var showFilePicker = false enum Phase: Equatable { @@ -103,6 +106,11 @@ struct ImportCSVView: View { Text("Importing \(importedCount) / \(novel.count)…") .font(.subheadline) .foregroundStyle(FlightTheme.textSecondary) + if enrichEnabled { + Text("Found aircraft type for \(enrichedCount)") + .font(.caption) + .foregroundStyle(FlightTheme.textTertiary) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -135,6 +143,19 @@ struct ImportCSVView: View { } header: { Text("Summary") } + Section { + Toggle(isOn: $enrichEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Look up aircraft type") + .font(.subheadline.weight(.semibold)) + Text("Adds a few seconds per flight via route-explorer schedule data. Old flights may not match.") + .font(.caption) + .foregroundStyle(FlightTheme.textSecondary) + } + } + } header: { + Text("Enrichment") + } Section { ForEach(Array(novel.prefix(50).enumerated()), id: \.offset) { _, p in VStack(alignment: .leading, spacing: 2) { @@ -166,6 +187,35 @@ struct ImportCSVView: View { } } + /// Look up the scheduled aircraft type from route-explorer for one + /// parsed row. Returns nil for old flights (no schedule data), + /// unmappable carriers, or network failures — those cases are + /// expected and we just save the flight without a type. + private func lookupAircraftType(for p: CSVFlightImporter.ParsedFlight) async -> String? { + guard let carrier = p.carrierIATA, + let numStr = p.flightNumber, + let num = Int(numStr) + else { return nil } + let day = Calendar.current.startOfDay(for: p.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 + ) + // Match the route too (some flight numbers fly different + // routes on different days; we want the one matching the + // user's dep/arr). + let exact = results.first { + $0.departure.airportIata == p.departureIATA + && $0.arrival.airportIata == p.arrivalIATA + } ?? results.first + let eq = exact?.equipmentIata + guard let eq, !eq.isEmpty else { return nil } + return eq.uppercased() + } + private func runParse(url: URL) async { phase = .parsing let didStart = url.startAccessingSecurityScopedResource() @@ -202,7 +252,17 @@ struct ImportCSVView: View { private func runImport() async { phase = .importing importedCount = 0 + enrichedCount = 0 for p in novel { + // Optionally look up the scheduled aircraft type via + // route-explorer for this carrier/flight/date so the + // Aircraft stats screen has data even from a bare CSV. + // Best-effort: silently skip on failure or no match. + let enrichedType: String? = enrichEnabled + ? await lookupAircraftType(for: p) + : nil + if enrichedType != nil { enrichedCount += 1 } + let f = LoggedFlight( flightDate: p.flightDate, carrierICAO: p.carrierICAO, @@ -212,7 +272,7 @@ struct ImportCSVView: View { arrivalIATA: p.arrivalIATA, scheduledDeparture: p.scheduledDeparture, scheduledArrival: nil, - aircraftType: nil, + aircraftType: enrichedType, registration: nil, icao24: nil, notes: p.pnr.map { "PNR: \($0)" },