diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 197b0ab..99638c3 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -81,6 +81,10 @@ HX1300001300000013000001 /* HistoryFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1300001300000013000002 /* HistoryFilters.swift */; }; HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.swift */; }; HX1500001500000015000001 /* AirportFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1500001500000015000002 /* AirportFlightsView.swift */; }; + HX1600001600000016000001 /* HistoryStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1600001600000016000002 /* HistoryStyle.swift */; }; + HX1700001700000017000001 /* PassportComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1700001700000017000002 /* PassportComponents.swift */; }; + HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; }; + HX1900001900000019000001 /* AircraftStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1900001900000019000002 /* AircraftStatsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -171,6 +175,10 @@ HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.swift; sourceTree = ""; }; HX1400001400000014000002 /* HistoryFilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilterSheet.swift; sourceTree = ""; }; HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = ""; }; + HX1600001600000016000002 /* HistoryStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryStyle.swift; sourceTree = ""; }; + HX1700001700000017000002 /* PassportComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportComponents.swift; sourceTree = ""; }; + HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = ""; }; + HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -220,6 +228,9 @@ HX1200001200000012000002 /* ImportCSVView.swift */, HX1400001400000014000002 /* HistoryFilterSheet.swift */, HX1500001500000015000002 /* AirportFlightsView.swift */, + HX1700001700000017000002 /* PassportComponents.swift */, + HX1800001800000018000002 /* PassportView.swift */, + HX1900001900000019000002 /* AircraftStatsView.swift */, AA5555555555555555555555 /* Styles */, AA6666666666666666666666 /* Components */, ); @@ -230,6 +241,7 @@ isa = PBXGroup; children = ( AA2222222222222222222222 /* FlightTheme.swift */, + HX1600001600000016000002 /* HistoryStyle.swift */, ); path = Styles; sourceTree = ""; @@ -507,6 +519,10 @@ HX1300001300000013000001 /* HistoryFilters.swift in Sources */, HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */, HX1500001500000015000001 /* AirportFlightsView.swift in Sources */, + HX1600001600000016000001 /* HistoryStyle.swift in Sources */, + HX1700001700000017000001 /* PassportComponents.swift in Sources */, + HX1800001800000018000001 /* PassportView.swift in Sources */, + HX1900001900000019000001 /* AircraftStatsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Flights/Views/AircraftStatsView.swift b/Flights/Views/AircraftStatsView.swift new file mode 100644 index 0000000..5eff405 --- /dev/null +++ b/Flights/Views/AircraftStatsView.swift @@ -0,0 +1,327 @@ +import SwiftUI + +/// Aircraft Stats screen — Total / Newest / Oldest header row, then a +/// ranked list of types you've flown with real airframe photos as row +/// backgrounds, plus a "Most Flown Tail" hero card at the bottom. +struct AircraftStatsView: View { + let allFlights: [LoggedFlight] + let store: FlightHistoryStore + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var scheme + + var body: some View { + ScrollView { + VStack(spacing: 16) { + header + topStatsRow + typeListSection + mostFlownTailSection + Spacer(minLength: 60) + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + .background(HistoryStyle.background(scheme).ignoresSafeArea()) + .navigationTitle("") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { dismiss() } label: { Image(systemName: "xmark") } + } + } + } + + private var header: some View { + VStack(spacing: 4) { + Text("AIRCRAFT") + .font(.system(size: 40, weight: .black)) + .tracking(-0.5) + .foregroundStyle(HistoryStyle.ink(scheme)) + Rectangle() + .fill(HistoryStyle.runwayOrange) + .frame(width: 38, height: 3) + } + .frame(maxWidth: .infinity) + .padding(.top, 8) + .padding(.bottom, 8) + } + + // MARK: - Top stats row (3 column tile bar) + + private var topStatsRow: some View { + HStack(spacing: 10) { + statTile(label: "Total", value: "\(uniqueTypeCodes.count)", subtitle: "types flown") + statTile( + label: "Newest", + value: newestAirframeAgeLabel(), + subtitle: newestAirframeYearLabel() + ) + statTile( + label: "Oldest", + value: oldestAirframeAgeLabel(), + subtitle: oldestAirframeYearLabel() + ) + } + } + + private func statTile(label: String, value: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label.uppercased()) + .font(HistoryStyle.label(10)) + .tracking(1.3) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + Text(value) + .font(HistoryStyle.displayNumber(22)) + .foregroundStyle(HistoryStyle.ink(scheme)) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(subtitle) + .font(.system(size: 11)) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 16)) + } + + // MARK: - Type list + + private var typeListSection: some View { + VStack(alignment: .leading, spacing: 12) { + HistorySectionLabel("By type") + VStack(spacing: 10) { + ForEach(rankedTypes, id: \.code) { item in + AircraftTypeCard(type: item) + } + } + } + .padding(.top, 8) + } + + struct TypeRanked { + let code: String + let displayName: String + let count: Int + let sampleRegistration: String? + } + + private var rankedTypes: [TypeRanked] { + let byType = Dictionary(grouping: allFlights.filter { $0.aircraftType != nil }) { $0.aircraftType! } + return byType.map { code, list in + TypeRanked( + code: code, + displayName: AircraftDatabase.shared.displayName(forTypeCode: code), + count: list.count, + sampleRegistration: list.compactMap { $0.registration }.first + ) + } + .sorted { $0.count > $1.count } + } + + // MARK: - Most-flown tail hero + + @ViewBuilder + private var mostFlownTailSection: some View { + if let top = mostFlownTail { + VStack(alignment: .leading, spacing: 12) { + HistorySectionLabel("Most flown airframe") + MostFlownTailCard(reg: top.reg, count: top.count, sample: top.sampleFlight) + } + .padding(.top, 8) + } + } + + private struct MostFlownTail { + let reg: String + let count: Int + let sampleFlight: LoggedFlight + } + + private var mostFlownTail: MostFlownTail? { + let byReg = Dictionary(grouping: allFlights.filter { $0.registration != nil }) { $0.registration! } + guard let top = byReg.max(by: { $0.value.count < $1.value.count }), + let sample = top.value.first + else { return nil } + return MostFlownTail(reg: top.key, count: top.value.count, sampleFlight: sample) + } + + // MARK: - Computed metadata helpers + + private var uniqueTypeCodes: Set { + Set(allFlights.compactMap { $0.aircraftType }) + } + + /// Look up airframes with metadata and find the youngest one we've + /// flown on. Falls back to a "—" if no airframe has firstFlight + /// data cached yet. + private func newestAirframeAgeLabel() -> String { + guard let m = airframeWith(.newest), let date = m.firstFlightDate else { return "—" } + let years = Calendar.current.dateComponents([.year], from: date, to: Date()).year ?? 0 + return "\(years)y" + } + private func newestAirframeYearLabel() -> String { + guard let m = airframeWith(.newest), let date = m.firstFlightDate else { return "—" } + let f = DateFormatter(); f.dateFormat = "yyyy" + return f.string(from: date) + } + private func oldestAirframeAgeLabel() -> String { + guard let m = airframeWith(.oldest), let date = m.firstFlightDate else { return "—" } + let years = Calendar.current.dateComponents([.year], from: date, to: Date()).year ?? 0 + return "\(years)y" + } + private func oldestAirframeYearLabel() -> String { + guard let m = airframeWith(.oldest), let date = m.firstFlightDate else { return "—" } + let f = DateFormatter(); f.dateFormat = "yyyy" + return f.string(from: date) + } + + private enum AirframePick { case newest, oldest } + + private func airframeWith(_ pick: AirframePick) -> AirframeMetadata? { + let regs = Set(allFlights.compactMap { $0.registration }) + let metas = regs.compactMap { store.airframe(for: $0) } + .filter { $0.firstFlightDate != nil } + switch pick { + case .newest: return metas.max(by: { ($0.firstFlightDate ?? .distantPast) < ($1.firstFlightDate ?? .distantPast) }) + case .oldest: return metas.min(by: { ($0.firstFlightDate ?? .distantPast) < ($1.firstFlightDate ?? .distantPast) }) + } + } +} + +// MARK: - Aircraft type card with photo background + +struct AircraftTypeCard: View { + let type: AircraftStatsView.TypeRanked + @State private var photo: AircraftPhotoService.Photo? + @Environment(\.colorScheme) private var scheme + + var body: some View { + ZStack(alignment: .bottomLeading) { + background + .frame(height: 120) + LinearGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)], + startPoint: .top, endPoint: .bottom + ) + .frame(height: 120) + + VStack(alignment: .leading, spacing: 4) { + Text(type.displayName.uppercased()) + .font(.system(size: 13, weight: .heavy)) + .tracking(1.5) + .foregroundStyle(.white) + Text(type.code) + .font(.system(size: 26, weight: .black).monospaced()) + .foregroundStyle(.white) + } + .padding(14) + + VStack(alignment: .trailing, spacing: 2) { + Text("\(type.count)") + .font(HistoryStyle.displayNumber(28)) + .foregroundStyle(HistoryStyle.runwayOrange) + Text("FLIGHTS") + .font(.system(size: 9, weight: .bold)) + .tracking(1.3) + .foregroundStyle(.white.opacity(0.7)) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .task(id: type.sampleRegistration ?? type.code) { + guard let reg = type.sampleRegistration else { return } + photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "") + } + } + + @ViewBuilder + private var background: some View { + if let url = photo?.largeURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): img.resizable().aspectRatio(contentMode: .fill) + default: HistoryStyle.cardSubtle(scheme) + } + } + } else { + ZStack { + HistoryStyle.cardSubtle(scheme) + Image(systemName: "airplane") + .font(.system(size: 60, weight: .heavy)) + .foregroundStyle(HistoryStyle.runwayOrange.opacity(0.25)) + .rotationEffect(.degrees(-15)) + } + } + } +} + +// MARK: - Most flown tail hero + +struct MostFlownTailCard: View { + let reg: String + let count: Int + let sample: LoggedFlight + @State private var photo: AircraftPhotoService.Photo? + @Environment(\.colorScheme) private var scheme + + var body: some View { + ZStack(alignment: .bottomLeading) { + background.frame(height: 240) + LinearGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)], + startPoint: .center, endPoint: .bottom + ) + .frame(height: 240) + + VStack(alignment: .leading, spacing: 6) { + Text("REPEAT OFFENDER") + .font(.system(size: 10, weight: .heavy)) + .tracking(1.8) + .foregroundStyle(HistoryStyle.runwayOrange) + Text(reg) + .font(.system(size: 42, weight: .black).monospaced()) + .foregroundStyle(.white) + HStack(spacing: 16) { + if let type = sample.aircraftType { + kvp(value: type, label: "Type") + } + kvp(value: "\(count)×", label: "Flown") + } + } + .padding(18) + } + .frame(height: 240) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .task(id: reg) { + photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "") + } + } + + @ViewBuilder + private var background: some View { + if let url = photo?.largeURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): img.resizable().aspectRatio(contentMode: .fill) + default: HistoryStyle.heroNavyGradient + } + } + } else { + HistoryStyle.heroNavyGradient + } + } + + private func kvp(value: String, label: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(value) + .font(.system(size: 14, weight: .heavy).monospaced()) + .foregroundStyle(.white) + Text(label.uppercased()) + .font(.system(size: 9, weight: .bold)) + .tracking(0.8) + .foregroundStyle(.white.opacity(0.7)) + } + } +} diff --git a/Flights/Views/HistoryDetailView.swift b/Flights/Views/HistoryDetailView.swift index f3271f6..34511ed 100644 --- a/Flights/Views/HistoryDetailView.swift +++ b/Flights/Views/HistoryDetailView.swift @@ -2,11 +2,11 @@ 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. +/// Single-flight detail screen — restyled with the passport palette. +/// Aircraft card now uses Flighty's labeled-grid pattern with +/// em-dashes for missing data. New "Detailed Timetable" card shows +/// scheduled vs actual when we have actual times, with late actuals +/// in red. struct HistoryDetailView: View { let flight: LoggedFlight let store: FlightHistoryStore @@ -15,34 +15,33 @@ struct HistoryDetailView: View { @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL + @Environment(\.colorScheme) private var scheme @State private var photo: AircraftPhotoService.Photo? @State private var track: AircraftTrack? @State private var editedNotes: String = "" @State private var showDeleteConfirm = false - /// Re-render trigger after we upsert airframe metadata. SwiftData - /// changes don't auto-invalidate non-@Query views. @State private var metadataLoaded = false var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 14) { header routeCard - photoBanner - .padding(.horizontal, -16) + photoBanner.padding(.horizontal, -16) if let cred = photo?.photographer { photoCredit(name: cred, link: photo?.detailLink) } mapSection aircraftCard + timetableCard notesSection deleteButton } .padding(16) } - .background(FlightTheme.background.ignoresSafeArea()) - .navigationTitle(flight.flightLabel) + .background(HistoryStyle.background(scheme).ignoresSafeArea()) + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .task { editedNotes = flight.notes ?? "" @@ -64,81 +63,92 @@ struct HistoryDetailView: View { // 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) + VStack(alignment: .leading, spacing: 4) { + Text(flight.flightLabel) + .font(.system(size: 38, weight: .black).monospaced()) + .foregroundStyle(HistoryStyle.ink(scheme)) + HStack(spacing: 8) { + Text(airlineName) + .font(.system(size: 14, weight: .semibold)) + Text("·") + Text(longDate(flight.flightDate).uppercased()) + .font(.system(size: 12, weight: .heavy).monospaced()) + .tracking(1) } - Text(airlineName) - .font(.subheadline) - .foregroundStyle(FlightTheme.textSecondary) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) } } - // MARK: - Route card + private var airlineName: String { + AircraftRegistry.shared.lookup(icao: flight.carrierICAO)?.name + ?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)?.name + ?? flight.carrierIATA ?? "Unknown" + } + + // MARK: - Route 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) + VStack(spacing: 14) { + HStack(alignment: .top) { + routeEndpoint(iata: flight.departureIATA, label: "From", time: flight.actualDeparture ?? flight.scheduledDeparture) + Spacer() Image(systemName: "airplane") - .font(.title3) - .foregroundStyle(FlightTheme.accent) + .font(.system(size: 24, weight: .heavy)) + .foregroundStyle(HistoryStyle.runwayOrange) .rotationEffect(.degrees(-45)) - endpoint(iata: flight.arrivalIATA, label: "Arrived", time: flight.actualArrival ?? flight.scheduledArrival) + Spacer() + routeEndpoint(iata: flight.arrivalIATA, label: "To", time: flight.actualArrival ?? flight.scheduledArrival) } - if let mi = store.distanceMiles(for: flight) { - Text("\(numberString(mi)) miles · \(durationDisplay)") - .font(.caption.monospaced()) - .foregroundStyle(FlightTheme.textTertiary) + Rectangle() + .fill(HistoryStyle.hairline(scheme)) + .frame(height: 0.5) + HStack(spacing: 18) { + if let mi = store.distanceMiles(for: flight) { + miniStat(label: "Distance", value: "\(numberString(mi)) mi") + } + miniStat(label: "Duration", value: durationDisplay) + Spacer() } } - .flightCard() + .historyCard(scheme, padding: 18) } - 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) + private func routeEndpoint(iata: String, label: String, time: Date?) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label.uppercased()) + .font(HistoryStyle.label(10)) + .tracking(1.3) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) Text(iata.isEmpty ? "—" : iata) - .font(FlightTheme.airportCode(28)) - .foregroundStyle(FlightTheme.textPrimary) + .font(.system(size: 32, weight: .black).monospaced()) + .foregroundStyle(HistoryStyle.ink(scheme)) if let m = database.airport(byIATA: iata) { Text(m.name) - .font(.caption2) - .foregroundStyle(FlightTheme.textSecondary) + .font(.system(size: 11)) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) .lineLimit(1) } if let time { Text(shortDateTime(time)) - .font(.caption2.monospaced()) - .foregroundStyle(FlightTheme.textTertiary) + .font(.system(size: 11, weight: .semibold).monospaced()) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) } } .frame(maxWidth: .infinity, alignment: .leading) } + private func miniStat(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()) + .font(HistoryStyle.label(9)) + .tracking(1.2) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + Text(value) + .font(.system(size: 14, weight: .heavy).monospaced()) + .foregroundStyle(HistoryStyle.ink(scheme)) + } + } + // MARK: - Photo @ViewBuilder @@ -146,10 +156,8 @@ struct HistoryDetailView: 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) + case .success(let img): img.resizable().aspectRatio(contentMode: .fill) + default: Rectangle().fill(HistoryStyle.cardSubtle(scheme)) } } .frame(maxWidth: .infinity) @@ -160,11 +168,11 @@ struct HistoryDetailView: View { private func photoCredit(name: String, link: URL?) -> some View { HStack(spacing: 4) { - Image(systemName: "camera.fill").font(.caption2) + Image(systemName: "camera.fill").font(.system(size: 9)) Text("Photo by \(name) · planespotters.net") - .font(.caption2) + .font(.system(size: 10)) } - .foregroundStyle(FlightTheme.textTertiary) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) .contentShape(Rectangle()) .onTapGesture { if let link { openURL(link) } } } @@ -174,10 +182,7 @@ struct HistoryDetailView: View { @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) + HistorySectionLabel(track == nil ? "Route" : "Flown path") FlightRouteMap( departureIATA: flight.departureIATA, arrivalIATA: flight.arrivalIATA, @@ -185,30 +190,20 @@ struct HistoryDetailView: View { database: database ) .frame(height: 220) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: 18)) } } private func loadTrackIfRecent() async { - // OpenSky's anonymous track endpoint trims history after ~7 - // days. Older logs get the great-circle fallback drawn by - // FlightRouteMap. let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400 guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return } track = await openSky.track(icao24: icao24) } - /// Hit OpenSky's metadata endpoint for first-flight / built dates. - /// We persist the result so subsequent views of the same airframe - /// don't re-query the network. Best-effort — many newer airframes - /// have no metadata yet. private func loadAirframeMetadata() async { - guard let reg = flight.registration, - !reg.isEmpty, - let icao24 = flight.icao24, - !icao24.isEmpty + guard let reg = flight.registration, !reg.isEmpty, + let icao24 = flight.icao24, !icao24.isEmpty else { return } - // Skip if we already have a cached entry with at least one date. if let cached = store.airframe(for: reg), cached.firstFlightDate != nil || cached.deliveryDate != nil { metadataLoaded.toggle() @@ -229,63 +224,147 @@ struct HistoryDetailView: View { 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) } + let firstFlight = airframe?.firstFlightDate + let ageYears = firstFlight.map { years(since: $0) } - return VStack(alignment: .leading, spacing: 8) { - Text("AIRCRAFT") - .font(FlightTheme.label()) - .foregroundStyle(FlightTheme.textTertiary) - .tracking(1) + return VStack(alignment: .leading, spacing: 12) { + HistorySectionLabel("Aircraft") 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" - ) - } - } + aircraftRow( + leftLabel: "Type", leftValue: flight.aircraftType ?? "—", + rightLabel: "Tail #", rightValue: flight.registration ?? "—" + ) + divider + aircraftRow( + leftLabel: "First flight", + leftValue: firstFlight.map { yearString($0) } ?? "—", + rightLabel: "Age", + rightValue: ageYears.map { "\($0) yr" } ?? "—" + ) + divider + aircraftRow( + leftLabel: "On this airframe", + leftValue: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time", + rightLabel: "ICAO24", + rightValue: flight.icao24?.uppercased() ?? "—" + ) } - .flightCard(padding: 0) + .historyCard(scheme, padding: 0) + } + } + + private var divider: some View { + Rectangle() + .fill(HistoryStyle.hairline(scheme)) + .frame(height: 0.5) + } + + private func aircraftRow(leftLabel: String, leftValue: String, rightLabel: String, rightValue: String) -> some View { + HStack(spacing: 0) { + cell(label: leftLabel, value: leftValue) + cell(label: rightLabel, value: rightValue) } } private func cell(label: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption2) - .foregroundStyle(FlightTheme.textTertiary) - .tracking(0.5) + VStack(alignment: .leading, spacing: 4) { + Text(label.uppercased()) + .font(HistoryStyle.label(10)) + .tracking(1.3) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) Text(value) - .font(.subheadline.weight(.semibold).monospaced()) - .foregroundStyle(FlightTheme.textPrimary) + .font(.system(size: 14, weight: .heavy).monospaced()) + .foregroundStyle(HistoryStyle.ink(scheme)) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) + .padding(14) + } + + // MARK: - Timetable + + @ViewBuilder + private var timetableCard: some View { + if hasTimetableData { + VStack(alignment: .leading, spacing: 12) { + HistorySectionLabel("Detailed timetable") + VStack(spacing: 0) { + timetableHeader + divider + timetableRow( + label: "Departure", + scheduled: flight.scheduledDeparture, + actual: flight.actualDeparture + ) + divider + timetableRow( + label: "Arrival", + scheduled: flight.scheduledArrival, + actual: flight.actualArrival + ) + } + .historyCard(scheme, padding: 0) + } + } + } + + private var hasTimetableData: Bool { + flight.scheduledDeparture != nil + || flight.scheduledArrival != nil + || flight.actualDeparture != nil + || flight.actualArrival != nil + } + + private var timetableHeader: some View { + HStack(spacing: 0) { + Text("") + .frame(maxWidth: .infinity, alignment: .leading) + Text("SCHEDULED") + .font(HistoryStyle.label(10)) + .tracking(1.2) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + .frame(maxWidth: .infinity, alignment: .leading) + Text("ACTUAL") + .font(HistoryStyle.label(10)) + .tracking(1.2) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 14) + .padding(.top, 14) + } + + private func timetableRow(label: String, scheduled: Date?, actual: Date?) -> some View { + let isLate: Bool = { + guard let scheduled, let actual else { return false } + return actual.timeIntervalSince(scheduled) > 5 * 60 + }() + return HStack(spacing: 0) { + Text(label) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(HistoryStyle.ink(scheme)) + .frame(maxWidth: .infinity, alignment: .leading) + Text(scheduled.map(shortTime) ?? "—") + .font(.system(size: 13, weight: .heavy).monospaced()) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + .frame(maxWidth: .infinity, alignment: .leading) + Text(actual.map(shortTime) ?? "—") + .font(.system(size: 13, weight: .heavy).monospaced()) + .foregroundStyle(isLate ? Color(red: 0.85, green: 0.15, blue: 0.15) : HistoryStyle.ink(scheme)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(14) } // MARK: - Notes private var notesSection: some View { VStack(alignment: .leading, spacing: 8) { - Text("NOTES") - .font(FlightTheme.label()) - .foregroundStyle(FlightTheme.textTertiary) - .tracking(1) + HistorySectionLabel("Notes") TextEditor(text: $editedNotes) + .scrollContentBackground(.hidden) .frame(minHeight: 80) .padding(8) - .background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10)) + .background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14)) .onChange(of: editedNotes) { _, newValue in flight.notes = newValue.isEmpty ? nil : newValue } @@ -302,25 +381,17 @@ struct HistoryDetailView: View { Image(systemName: "trash") Text("Delete flight") } + .font(.system(size: 14, weight: .semibold)) .frame(maxWidth: .infinity) - .padding(.vertical, 12) + .padding(.vertical, 14) } - .background(FlightTheme.cancelled.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) - .foregroundStyle(FlightTheme.cancelled) + .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) + .foregroundStyle(Color.red) .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 @@ -329,8 +400,7 @@ struct HistoryDetailView: View { } private func numberString(_ n: Int) -> String { - let f = NumberFormatter() - f.numberStyle = .decimal + let f = NumberFormatter(); f.numberStyle = .decimal return f.string(from: NSNumber(value: n)) ?? "\(n)" } @@ -338,6 +408,11 @@ struct HistoryDetailView: View { Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0 } + private func yearString(_ d: Date) -> String { + let f = DateFormatter(); f.dateFormat = "yyyy" + return f.string(from: d) + } + private func ordinalSuffix(_ n: Int) -> String { let r = n % 100 if r >= 11 && r <= 13 { return "th" } @@ -350,14 +425,17 @@ struct HistoryDetailView: View { } private func longDate(_ d: Date) -> String { - let f = DateFormatter() - f.dateFormat = "MMM d, yyyy" + let f = DateFormatter(); f.dateFormat = "EEE, MMM d, yyyy" return f.string(from: d) } private func shortDateTime(_ d: Date) -> String { - let f = DateFormatter() - f.dateFormat = "MMM d, HH:mm" + let f = DateFormatter(); f.dateFormat = "MMM d, HH:mm" + return f.string(from: d) + } + + private func shortTime(_ d: Date) -> String { + let f = DateFormatter(); f.dateFormat = "h:mm a" return f.string(from: d) } } @@ -374,29 +452,26 @@ private struct FlightRouteMap: View { Map { if let dep = database.airport(byIATA: departureIATA) { Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate) - .tint(FlightTheme.onTime) + .tint(HistoryStyle.stampGreen) } if let arr = database.airport(byIATA: arrivalIATA) { Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate) - .tint(FlightTheme.accent) + .tint(HistoryStyle.runwayOrange) } if let track { let coords = track.path.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } MapPolyline(coordinates: coords) - .stroke(FlightTheme.accent, lineWidth: 3) + .stroke(HistoryStyle.runwayOrange, 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])) + .stroke(HistoryStyle.runwayOrange.opacity(0.7), 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 diff --git a/Flights/Views/HistoryRouteMapView.swift b/Flights/Views/HistoryRouteMapView.swift index 4c96ad5..7dc7a75 100644 --- a/Flights/Views/HistoryRouteMapView.swift +++ b/Flights/Views/HistoryRouteMapView.swift @@ -23,6 +23,7 @@ struct HistoryRouteMapView: View { @State private var selectedAirportSheet: AirportSheet? @State private var selectedFlight: LoggedFlight? @State private var revealKey: Int = 0 // bump to retrigger the reveal animation + @State private var drawerExpanded: Bool = false struct AirportSheet: Identifiable { let iata: String @@ -32,25 +33,33 @@ struct HistoryRouteMapView: View { var body: some View { let arcs = self.arcs - return Map(position: $position) { - // Airport dots - ForEach(airportItems, id: \.iata) { item in - Annotation(item.iata, coordinate: item.coord) { - AirportDot( - size: dotSize(for: item.count), - isSelected: filters.airports.contains(item.iata) - ) - .onTapGesture { selectedAirportSheet = AirportSheet(iata: item.iata) } + return ZStack(alignment: .bottom) { + Map(position: $position) { + // Airport dots + ForEach(airportItems, id: \.iata) { item in + Annotation(item.iata, coordinate: item.coord) { + AirportDot( + size: dotSize(for: item.count), + isSelected: filters.airports.contains(item.iata) + ) + .onTapGesture { selectedAirportSheet = AirportSheet(iata: item.iata) } + } + .annotationTitles(.hidden) + } + // Animated arcs + ForEach(arcs.prefix(revealCount)) { arc in + MapPolyline(coordinates: arc.coords) + .stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 3.0 : 1.6) } - .annotationTitles(.hidden) - } - // Animated arcs - ForEach(arcs.prefix(revealCount)) { arc in - MapPolyline(coordinates: arc.coords) - .stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 2.5 : 1.5) } + .mapStyle(.imagery(elevation: .flat)) + .ignoresSafeArea(edges: .bottom) + + // Bottom passport drawer — always-visible card peeking up + // from the bottom of the map, showing the scoped passport + // summary and the active filter set. Tap to expand. + passportDrawer } - .mapStyle(.standard(elevation: .flat)) .navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -125,10 +134,101 @@ struct HistoryRouteMapView: View { private func arcColor(for arc: Arc) -> Color { if arc.isMostRecent { - return FlightTheme.onTime + return Color(red: 1.0, green: 1.0, blue: 0.0) // fluorescent yellow for the latest leg } - // Slight transparency on bulk lines so the most-recent stands out. - return FlightTheme.accent.opacity(0.55) + // Bulk lines in vivid runway orange. + return HistoryStyle.runwayOrange + } + + // MARK: - Passport drawer + + @ViewBuilder + private var passportDrawer: some View { + VStack(spacing: 8) { + // Tab handle + Capsule() + .fill(.white.opacity(0.4)) + .frame(width: 36, height: 5) + .padding(.top, 8) + + VStack(spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(filters.isEmpty ? "ALL TIME" : "FILTERED") + .font(.system(size: 10, weight: .heavy)) + .tracking(2) + .foregroundStyle(HistoryStyle.runwayOrange) + Text("\(flights.count) flights · \(numberStringMiles())") + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(.white) + } + Spacer() + Button { + revealCount = 0 + revealKey += 1 + } label: { + Image(systemName: "play.fill") + .font(.system(size: 12, weight: .bold)) + .padding(10) + .background(.white.opacity(0.18), in: Circle()) + .foregroundStyle(.white) + } + } + .padding(.horizontal, 18) + + // Filter chips (live) + if !filters.airports.isEmpty || !filters.airlines.isEmpty || !filters.years.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in + drawerChip("\(y)") { filters.years.remove(y) } + } + ForEach(Array(filters.airlines).sorted(), id: \.self) { a in + drawerChip(a) { filters.airlines.remove(a) } + } + ForEach(Array(filters.airports).sorted(), id: \.self) { a in + drawerChip(a) { filters.airports.remove(a) } + } + } + .padding(.horizontal, 18) + } + } + } + .padding(.bottom, 22) + } + .frame(maxWidth: .infinity) + .background( + LinearGradient( + colors: [HistoryStyle.midnightNavy.opacity(0.92), HistoryStyle.midnightNavy], + startPoint: .top, endPoint: .bottom + ) + .clipShape(UnevenRoundedRectangle(topLeadingRadius: 22, topTrailingRadius: 22)) + .ignoresSafeArea(edges: .bottom) + ) + .shadow(color: .black.opacity(0.4), radius: 12, y: -4) + } + + private func drawerChip(_ label: String, onRemove: @escaping () -> Void) -> some View { + HStack(spacing: 4) { + Text(label) + .font(.system(size: 11, weight: .bold).monospaced()) + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(HistoryStyle.runwayOrange, in: Capsule()) + .onTapGesture(perform: onRemove) + } + + private func numberStringMiles() -> String { + let total = flights.reduce(0) { acc, f in + acc + (store.distanceMiles(for: f) ?? 0) + } + let f = NumberFormatter() + f.numberStyle = .decimal + return (f.string(from: NSNumber(value: total)) ?? "\(total)") + " mi" } // MARK: - Airports @@ -199,12 +299,10 @@ private struct AirportDot: View { var body: some View { Circle() - .fill(isSelected ? FlightTheme.onTime : FlightTheme.accent) + .fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange) .frame(width: size, height: size) - .overlay( - Circle().stroke(.white, lineWidth: 1.5) - ) - .shadow(color: .black.opacity(0.2), radius: 1, y: 1) + .overlay(Circle().stroke(.white, lineWidth: 2)) + .shadow(color: .black.opacity(0.5), radius: 2, y: 1) .contentShape(Circle()) } } diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index da8281f..e14850b 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -1,25 +1,31 @@ import SwiftUI import SwiftData -/// Top-level history tab. Totals strip + sortable / filterable / searchable -/// list of every flight you've logged, plus entry points to the lifetime -/// stats / route map / year-in-review screens. +/// History tab — redesigned as a "passport" experience. +/// +/// Stacked hero cards at the top (current-year passport, all-time +/// passport, most-flown airframe), a horizontal year tab strip that +/// scopes everything, and a flight feed below. Sort + filter + search +/// + add affordances all live in the toolbar. struct HistoryView: View { let database: AirportDatabase let routeExplorer: RouteExplorerClient let openSky: OpenSkyClient @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var scheme @Query(sort: \LoggedFlight.flightDate, order: .reverse) private var flights: [LoggedFlight] @State private var filters: HistoryFilters = .init() @State private var sort: HistorySort = .newestFirst + @State private var selectedYear: Int? = nil // nil = ALL @State private var showingAdd = false - @State private var showingStats = false + @State private var showingPassport = false @State private var showingMap = false + @State private var showingAircraftStats = false @State private var showingCalendarImport = false @State private var showingCSVImport = false @State private var showingYearInReview = false @@ -27,54 +33,43 @@ struct HistoryView: View { var body: some View { let store = FlightHistoryStore(context: modelContext, airportDatabase: database) - let visible = filteredSorted(store: store) - let stats = StatsEngine(store: store, database: database, flights: visible) + let scoped = scopedFlights(store: store) + let stats = StatsEngine(store: store, database: database, flights: scoped) - return List { - if !flights.isEmpty { - Section { - totalsStrip(stats: stats, isFiltered: !filters.isEmpty) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) + ScrollView { + LazyVStack(spacing: 0, pinnedViews: []) { + titleHeader + + YearTabStrip(years: yearsList, selection: $selectedYear) + .padding(.vertical, 12) + + if filters.isEmpty { + heroDeck(store: store, stats: stats) + .padding(.horizontal, 16) + .padding(.bottom, 8) } + if !filters.isEmpty { - Section { - activeChips - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) - .listRowBackground(Color.clear) - } + activeChips + .padding(.horizontal, 16) + .padding(.bottom, 8) } - } - ForEach(groups(visible), id: \.key) { group in - Section(header: Text(group.key)) { - 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 scoped.isEmpty { + emptyState + } else { + flightFeed(scoped, store: store) } - } - if flights.isEmpty { - emptyState - } else if visible.isEmpty { - noMatchState + + Spacer(minLength: 80) } } - .listStyle(.insetGrouped) - .navigationTitle("History") - .searchable(text: $filters.query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Flight #, airport, route") + .background(HistoryStyle.background(scheme).ignoresSafeArea()) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $filters.query, prompt: "Flight #, airport, route") .toolbar { - ToolbarItem(placement: .secondaryAction) { + ToolbarItem(placement: .topBarTrailing) { Menu { ForEach(HistorySort.allCases) { option in Button { @@ -88,57 +83,39 @@ struct HistoryView: View { } } } label: { - Label("Sort", systemImage: "arrow.up.arrow.down") + Image(systemName: "arrow.up.arrow.down") } } - ToolbarItem(placement: .secondaryAction) { + ToolbarItem(placement: .topBarTrailing) { Button { showingFilterSheet = true } label: { - if filters.activeCount > 0 { - Label("Filters (\(filters.activeCount))", systemImage: "line.3.horizontal.decrease.circle.fill") - } else { - Label("Filters", systemImage: "line.3.horizontal.decrease.circle") - } + Image(systemName: filters.activeCount > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } } - ToolbarItem(placement: .primaryAction) { + 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") - } - 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") - } + 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") } } label: { Image(systemName: "plus.circle.fill") - .font(.title3) + .foregroundStyle(HistoryStyle.runwayOrange) } } } .sheet(isPresented: $showingAdd) { AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil) } - .sheet(isPresented: $showingStats) { - NavigationStack { LifetimeStatsView(stats: stats) } + .sheet(isPresented: $showingPassport) { + NavigationStack { + PassportView(stats: stats, allFlights: flights, database: database, store: store, selectedYear: $selectedYear) + } } .sheet(isPresented: $showingMap) { NavigationStack { HistoryRouteMapView( - flights: visible, + flights: scoped, allFlights: flights, database: database, openSky: openSky, @@ -147,6 +124,11 @@ struct HistoryView: View { ) } } + .sheet(isPresented: $showingAircraftStats) { + NavigationStack { + AircraftStatsView(allFlights: flights, store: store) + } + } .sheet(isPresented: $showingCalendarImport) { CalendarImportView(routeExplorer: routeExplorer, database: database, store: store) } @@ -154,7 +136,7 @@ struct HistoryView: View { ImportCSVView(store: store) } .sheet(isPresented: $showingYearInReview) { - YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date())) + YearInReviewView(stats: stats, year: selectedYear ?? Calendar.current.component(.year, from: Date())) } .sheet(isPresented: $showingFilterSheet) { HistoryFilterSheet(allFlights: flights, filters: $filters) @@ -162,34 +144,169 @@ struct HistoryView: View { } } - // MARK: - Sort/filter pipeline + // MARK: - Pipeline - private func filteredSorted(store: FlightHistoryStore) -> [LoggedFlight] { - let filtered = flights.filter { filters.matches($0) } - let comparator = sort.comparator { store.distanceMiles(for: $0) ?? 0 } - return filtered.sorted(by: comparator) + private var yearsList: [Int] { + let cal = Calendar.current + let ys = Set(flights.map { cal.component(.year, from: $0.flightDate) }) + return ys.sorted(by: >) } - // MARK: - Grouping - - private struct Group { - let key: String - let flights: [LoggedFlight] - } - - /// Group by year when the sort is date-based; flat list otherwise so - /// "By airline" doesn't shred a continuous airline run across sections. - private func groups(_ list: [LoggedFlight]) -> [Group] { - switch sort { - case .newestFirst, .oldestFirst: + private func scopedFlights(store: FlightHistoryStore) -> [LoggedFlight] { + var scoped = flights + if let y = selectedYear { let cal = Calendar.current - let grouped = Dictionary(grouping: list) { cal.component(.year, from: $0.flightDate) } - let order: (Int, Int) -> Bool = sort == .newestFirst ? (>) : (<) - return grouped - .map { Group(key: String($0.key), flights: $0.value) } - .sorted { order(Int($0.key) ?? 0, Int($1.key) ?? 0) } - case .longestFirst, .shortestFirst, .airline, .flightNumber: - return [Group(key: sort.rawValue, flights: list)] + scoped = scoped.filter { cal.component(.year, from: $0.flightDate) == y } + } + scoped = scoped.filter { filters.matches($0) } + let cmp = sort.comparator { store.distanceMiles(for: $0) ?? 0 } + return scoped.sorted(by: cmp) + } + + // MARK: - Title + + private var titleHeader: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("PASSPORT") + .font(.system(size: 34, weight: .black)) + .tracking(-0.5) + .foregroundStyle(HistoryStyle.ink(scheme)) + if !flights.isEmpty { + Text("\(flights.count) flights · \(years(of: flights)) years") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + } + } + Spacer() + Image(systemName: "airplane") + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(HistoryStyle.runwayOrange) + .rotationEffect(.degrees(-45)) + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + + private func years(of list: [LoggedFlight]) -> Int { + let yrs = Set(list.map { Calendar.current.component(.year, from: $0.flightDate) }) + return yrs.count + } + + // MARK: - Hero deck + + @ViewBuilder + private func heroDeck(store: FlightHistoryStore, stats: StatsEngine) -> some View { + VStack(spacing: 12) { + // 1) Scoped passport — either current year or all-time + let year = selectedYear ?? Calendar.current.component(.year, from: Date()) + let yearFlights = stats.flights(for: year) + let yearStats = StatsEngine(store: store, database: database, flights: yearFlights) + + Button { showingPassport = true } label: { + if selectedYear == nil { + HeroStatCard( + label: "ALL TIME PASSPORT", + value: numberString(stats.totalFlights), + subtitle: "\(stats.shortDistance) miles · \(stats.shortDuration)h in air", + variant: .orange + ) { + HStack(spacing: 14) { + kvp(value: "\(stats.uniqueAirports)", label: "airports") + kvp(value: "\(stats.uniqueAirlines)", label: "airlines") + kvp(value: "\(stats.uniqueCountries)", label: "countries") + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + } + } + } else { + HeroStatCard( + label: "\(year) PASSPORT", + value: numberString(yearStats.totalFlights), + subtitle: "\(yearStats.shortDistance) miles · \(yearStats.shortDuration)h aloft", + variant: .orange + ) { + HStack(spacing: 14) { + kvp(value: "\(yearStats.uniqueAirports)", label: "airports") + kvp(value: "\(yearStats.uniqueAirlines)", label: "airlines") + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + } + .buttonStyle(.plain) + + // 2) Most-flown aircraft, if we know it + mostFlownCard(stats: stats) + + // 3) Quick links row + quickLinks + } + } + + @ViewBuilder + private func mostFlownCard(stats: StatsEngine) -> some View { + let typeCounts = Dictionary(grouping: stats.flights.compactMap { $0.aircraftType }) { $0 } + .mapValues(\.count) + if let top = typeCounts.max(by: { $0.value < $1.value }) { + let typeName = AircraftDatabase.shared.displayName(forTypeCode: top.key) + Button { showingAircraftStats = true } label: { + HeroStatCard( + label: "MOST FLOWN AIRCRAFT", + value: typeName == top.key ? top.key : typeName, + subtitle: "\(top.value) flights", + variant: .navy + ) { + HStack { + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + private var quickLinks: some View { + HStack(spacing: 10) { + quickLink(title: "Map", icon: "map.fill") { showingMap = true } + quickLink(title: "Aircraft", icon: "airplane.circle.fill") { showingAircraftStats = true } + quickLink(title: "Year", icon: "sparkles") { showingYearInReview = true } + } + } + + private func quickLink(title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .tracking(0.5) + } + .foregroundStyle(HistoryStyle.ink(scheme)) + .frame(maxWidth: .infinity, minHeight: 64) + .background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 16)) + } + } + + private func kvp(value: String, label: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(value) + .font(.system(size: 18, weight: .heavy).monospacedDigit()) + .foregroundStyle(.white) + Text(label.uppercased()) + .font(.system(size: 9, weight: .bold)) + .tracking(0.8) + .foregroundStyle(.white.opacity(0.7)) } } @@ -215,13 +332,11 @@ struct HistoryView: View { ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) } } - Button { - filters = HistoryFilters() - } label: { + Button { filters = HistoryFilters() } label: { Text("Clear") .font(.caption.weight(.semibold)) - .foregroundStyle(FlightTheme.accent) - .padding(.horizontal, 12) + .foregroundStyle(HistoryStyle.runwayOrange) + .padding(.horizontal, 10) .padding(.vertical, 6) } } @@ -237,82 +352,200 @@ struct HistoryView: View { .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(FlightTheme.accent, in: Capsule()) + .background(HistoryStyle.runwayOrange, in: Capsule()) .onTapGesture(perform: onRemove) } - // MARK: - Totals strip + // MARK: - Flight feed - private func totalsStrip(stats: StatsEngine, isFiltered: Bool) -> some View { - VStack(spacing: 6) { - if isFiltered { - Text("FILTERED TOTALS") - .font(.caption2.weight(.semibold)) - .tracking(0.8) - .foregroundStyle(FlightTheme.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - } - 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") + @ViewBuilder + private func flightFeed(_ scoped: [LoggedFlight], store: FlightHistoryStore) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + HistorySectionLabel(selectedYear == nil ? "Recent flights" : "Flights in \(selectedYear!)") + Spacer() + Text("\(scoped.count)") + .font(.system(size: 12, weight: .bold).monospacedDigit()) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) } .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.top, 8) + + ForEach(groupedFeed(scoped), id: \.key) { group in + if groupedFeed(scoped).count > 1 { + Text(group.key) + .font(.system(size: 12, weight: .bold).monospacedDigit()) + .tracking(0.6) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + .padding(.horizontal, 16) + .padding(.top, 8) + } + ForEach(group.flights) { f in + NavigationLink { + HistoryDetailView(flight: f, store: store, database: database, openSky: openSky) + } label: { + PassportFlightRow(flight: f, database: database) + .padding(.horizontal, 16) + } + .buttonStyle(.plain) + } + } } } - 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)) + private struct FeedGroup { + let key: String + let flights: [LoggedFlight] } - // MARK: - Empty states + private func groupedFeed(_ list: [LoggedFlight]) -> [FeedGroup] { + let cal = Calendar.current + switch sort { + case .newestFirst, .oldestFirst: + // When scoped to a single year, sub-group by month for + // visual rhythm; when ALL is selected, group by year. + if selectedYear != nil { + let grouped = Dictionary(grouping: list) { f -> String in + let comps = cal.dateComponents([.year, .month], from: f.flightDate) + let m = DateFormatter() + m.dateFormat = "MMMM" + return m.string(from: cal.date(from: comps) ?? f.flightDate).uppercased() + } + return grouped.map { FeedGroup(key: $0.key, flights: $0.value.sorted { sort == .newestFirst ? $0.flightDate > $1.flightDate : $0.flightDate < $1.flightDate }) } + .sorted { firstFlightDate($0.flights) > firstFlightDate($1.flights) } + } else { + let grouped = Dictionary(grouping: list) { String(cal.component(.year, from: $0.flightDate)) } + let order: (String, String) -> Bool = sort == .newestFirst ? (>) : (<) + return grouped.map { FeedGroup(key: $0.key, flights: $0.value) } + .sorted { order($0.key, $1.key) } + } + case .longestFirst, .shortestFirst, .airline, .flightNumber: + return [FeedGroup(key: "", flights: list)] + } + } + + private func firstFlightDate(_ list: [LoggedFlight]) -> Date { + list.first?.flightDate ?? .distantPast + } + + // MARK: - Empty state private var emptyState: some View { - VStack(spacing: 10) { + VStack(spacing: 12) { 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, import a CSV, or tap an aircraft on the Live tab.") - .font(.caption) - .multilineTextAlignment(.center) - .foregroundStyle(FlightTheme.textTertiary) - .padding(.horizontal, 24) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + Text(flights.isEmpty ? "No flights logged yet" : "No matches in \(selectedYear.map(String.init) ?? "this filter")") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + if flights.isEmpty { + Text("Tap + to add a flight, scan your calendar, or import a CSV.") + .font(.caption) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } else { + Button("Clear filter") { + selectedYear = nil + filters = HistoryFilters() + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(HistoryStyle.runwayOrange) + } } .frame(maxWidth: .infinity) - .padding(.vertical, 40) - .listRowBackground(Color.clear) + .padding(.vertical, 60) } - private var noMatchState: some View { - VStack(spacing: 10) { - Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 36)) - .foregroundStyle(FlightTheme.textTertiary) - Text("No flights match these filters") - .font(.subheadline) - .foregroundStyle(FlightTheme.textSecondary) - Button("Clear filters") { filters = HistoryFilters() } - .font(.subheadline) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 28) - .listRowBackground(Color.clear) + private func numberString(_ n: Int) -> String { + let f = NumberFormatter() + f.numberStyle = .decimal + return f.string(from: NSNumber(value: n)) ?? "\(n)" + } +} + +// MARK: - Passport-styled flight row + +struct PassportFlightRow: View { + let flight: LoggedFlight + let database: AirportDatabase + @State private var photo: AircraftPhotoService.Photo? + @Environment(\.colorScheme) private var scheme + + var body: some View { + HStack(spacing: 12) { + thumbnail + .frame(width: 56, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(flight.flightLabel) + .font(.system(size: 14, weight: .heavy).monospaced()) + .foregroundStyle(HistoryStyle.ink(scheme)) + if let type = flight.aircraftType { + Text(type) + .font(.system(size: 10, weight: .bold).monospaced()) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(HistoryStyle.cardSubtle(scheme), in: RoundedRectangle(cornerRadius: 4)) + } + } + HStack(spacing: 6) { + Text(flight.departureIATA) + .font(.system(size: 13, weight: .heavy).monospaced()) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + Image(systemName: "arrow.right") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(HistoryStyle.runwayOrange) + Text(flight.arrivalIATA) + .font(.system(size: 13, weight: .heavy).monospaced()) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + } + } + Spacer() + Text(shortDate(flight.flightDate)) + .font(.system(size: 11, weight: .semibold).monospacedDigit()) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14)) + .task(id: flight.registration ?? flight.id.uuidString) { + guard let reg = flight.registration, !reg.isEmpty else { return } + photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.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 { + HistoryStyle.cardSubtle(scheme) + Image(systemName: "airplane") + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(HistoryStyle.runwayOrange) + .rotationEffect(.degrees(-45)) + } + } + + private func shortDate(_ d: Date) -> String { + let f = DateFormatter() + f.dateFormat = "d MMM" + return f.string(from: d).uppercased() } } diff --git a/Flights/Views/PassportComponents.swift b/Flights/Views/PassportComponents.swift new file mode 100644 index 0000000..78ff1e7 --- /dev/null +++ b/Flights/Views/PassportComponents.swift @@ -0,0 +1,280 @@ +import SwiftUI + +// MARK: - Hero stat card +// +// Full-bleed colored card with one big number + label + optional +// subtitle. The variant determines the background treatment (orange, +// navy, gold, or a photo). Shared by HistoryView, PassportView, +// AircraftStatsView, and YearInReviewView so cards feel consistent +// wherever they appear. + +struct HeroStatCard: View { + let label: String + let value: String + let subtitle: String? + let variant: Variant + let onForeground: Color + @ViewBuilder var footer: () -> Footer + + enum Variant { + case orange + case navy + case gold + case green + case photo(URL?) + } + + init( + label: String, + value: String, + subtitle: String? = nil, + variant: Variant, + onForeground: Color = .white, + @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } + ) { + self.label = label + self.value = value + self.subtitle = subtitle + self.variant = variant + self.onForeground = onForeground + self.footer = footer + } + + var body: some View { + ZStack(alignment: .topLeading) { + background + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(HistoryStyle.label(11)) + .tracking(1.6) + .textCase(.uppercase) + .foregroundStyle(onForeground.opacity(0.7)) + + Text(value) + .font(HistoryStyle.displayNumber(46)) + .foregroundStyle(onForeground) + .lineLimit(1) + .minimumScaleFactor(0.55) + + if let subtitle { + Text(subtitle) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(onForeground.opacity(0.82)) + } + Spacer(minLength: 0) + footer() + } + .padding(20) + } + .frame(minHeight: 152) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + + @ViewBuilder + private var background: some View { + switch variant { + case .orange: + HistoryStyle.heroOrangeGradient + case .navy: + HistoryStyle.heroNavyGradient + case .gold: + HistoryStyle.heroGoldGradient + case .green: + HistoryStyle.heroGreenGradient + case .photo(let url): + ZStack { + Color.black + if let url { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().aspectRatio(contentMode: .fill) + default: + Color.black.opacity(0.6) + } + } + } + LinearGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)], + startPoint: .top, endPoint: .bottom + ) + } + } + } +} + +// MARK: - Year tab strip +// +// Horizontal scroll of `ALL · 2026 · 2025 · 2024 ...`. Tapping a year +// updates a binding the parent view filters against. Active year is +// pill-highlighted in runway orange. + +struct YearTabStrip: View { + let years: [Int] + @Binding var selection: Int? + @Environment(\.colorScheme) private var scheme + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { proxy in + HStack(spacing: 8) { + tab(label: "ALL", id: -1, selected: selection == nil) { + selection = nil + } + .id("ALL") + ForEach(years, id: \.self) { y in + tab(label: String(y), id: y, selected: selection == y) { + selection = y + } + .id(y) + } + } + .padding(.horizontal, 16) + .onAppear { + if let sel = selection { + withAnimation { proxy.scrollTo(sel, anchor: .center) } + } + } + } + } + } + + private func tab(label: String, id: Int, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.system(size: 13, weight: .bold).monospacedDigit()) + .tracking(0.6) + .foregroundStyle(selected ? .white : HistoryStyle.inkSecondary(scheme)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule() + .fill(selected ? HistoryStyle.runwayOrange : HistoryStyle.card(scheme)) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - Passport stamp badge +// +// Faux rubber-stamp circular badge used on cards to add flavor (e.g. +// "VERIFIED", date stamps). + +struct PassportStamp: View { + let text: String + let color: Color + + var body: some View { + Circle() + .stroke(color, lineWidth: 1.2) + .frame(width: 56, height: 56) + .overlay( + Text(text) + .font(.system(size: 10, weight: .black)) + .tracking(1.5) + .foregroundStyle(color) + .multilineTextAlignment(.center) + .padding(6) + ) + .rotationEffect(.degrees(-6)) + .opacity(0.85) + } +} + +// MARK: - OCR-passport flex footer +// +// The deeply unserious passport-bottom OCR text Flighty uses. We +// generate ours from the user's display name + a synthetic issue +// date. Pure flavor, fully optional. + +struct OCRPassportFooter: View { + let owner: String // "TARTT, GARY" + let issued: Date + + @Environment(\.colorScheme) private var scheme + + var body: some View { + VStack(spacing: 2) { + line(prefix: "P some View { + HStack(spacing: 0) { + Text(prefix + trailing) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + } + + private var nameLine: String { + let upper = owner.uppercased() + let padded = (upper + String(repeating: "<", count: 40)) + return String(padded.prefix(40)) + "<<<<<<<<" + } + + private var tailLine: String { + let f = DateFormatter() + f.dateFormat = "ddMMMyy" + f.locale = Locale(identifier: "en_US_POSIX") + let date = f.string(from: issued).uppercased() + return date + "<) + } + + /// Stats for the current scope — either lifetime or one year. + private var scopedStats: StatsEngine { + guard let y = selectedYear else { return stats } + let filtered = allFlights.filter { Calendar.current.component(.year, from: $0.flightDate) == y } + return StatsEngine(store: store, database: database, flights: filtered) + } + + @ViewBuilder + private var cards: some View { + let s = scopedStats + VStack(spacing: 12) { + HeroStatCard( + label: "FLIGHTS", + value: numberString(s.totalFlights), + subtitle: "across \(s.uniqueAirports) airports", + variant: .orange + ) { + HStack(spacing: 16) { + StatColumn(label: "Airlines", value: "\(s.uniqueAirlines)") + StatColumn(label: "Aircraft", value: "\(s.uniqueAircraftTypes)") + StatColumn(label: "Countries", value: "\(s.uniqueCountries)") + } + .padding(.top, 4) + } + + HeroStatCard( + label: "DISTANCE", + value: s.shortDistance + " mi", + subtitle: equatorComparison(miles: s.totalMiles), + variant: .navy + ) { EmptyView() } + + HeroStatCard( + label: "TIME ALOFT", + value: hoursAloftDisplay(s.totalMinutes), + subtitle: timeAloftSubtitle(s.totalMinutes), + variant: .gold, + onForeground: .white + ) { EmptyView() } + + if let top = s.topRoute { + HeroStatCard( + label: "TOP ROUTE", + value: top.label.replacingOccurrences(of: "↔", with: " ↔ "), + subtitle: "\(top.count) trips", + variant: .green + ) { EmptyView() } + } + + if let topAirline = s.topAirline { + let name = AircraftRegistry.shared.lookup(icao: topAirline.icao)?.name ?? topAirline.icao + HeroStatCard( + label: "TOP AIRLINE", + value: name, + subtitle: "\(topAirline.count) flights", + variant: .orange + ) { EmptyView() } + } + + if let longest = s.longestFlight, + let miles = store.distanceMiles(for: longest) { + HeroStatCard( + label: "LONGEST FLIGHT", + value: "\(longest.departureIATA) → \(longest.arrivalIATA)", + subtitle: "\(numberString(miles)) mi · \(shortDate(longest.flightDate))", + variant: .navy + ) { EmptyView() } + } + + repeatedTailsCard(s) + } + .padding(.horizontal, 16) + } + + @ViewBuilder + private func repeatedTailsCard(_ s: StatsEngine) -> some View { + let tails = s.repeatedTails.prefix(5) + if !tails.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HistorySectionLabel("Airframes you've repeated") + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + VStack(spacing: 0) { + ForEach(Array(tails.enumerated()), id: \.offset) { index, item in + HStack { + Text(item.reg) + .font(.system(size: 14, weight: .bold).monospaced()) + .foregroundStyle(HistoryStyle.ink(scheme)) + Spacer() + Text("\(item.count)×") + .font(.system(size: 14, weight: .black).monospacedDigit()) + .foregroundStyle(HistoryStyle.runwayOrange) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + if index < tails.count - 1 { + Rectangle() + .fill(HistoryStyle.hairline(scheme)) + .frame(height: 0.5) + .padding(.horizontal, 16) + } + } + } + } + .padding(.vertical, 14) + .background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 22)) + } + } + + // MARK: - Helpers + + private func numberString(_ n: Int) -> String { + let f = NumberFormatter() + f.numberStyle = .decimal + return f.string(from: NSNumber(value: n)) ?? "\(n)" + } + + private func equatorComparison(miles: Int) -> String { + let equator = 24_901 + let ratio = Double(miles) / Double(equator) + if miles == 0 { return "—" } + if ratio < 0.05 { return "miles flown" } + if ratio < 1 { return String(format: "%.0f%% of the way around earth", ratio * 100) } + return String(format: "%.1f× around the equator", ratio) + } + + private func hoursAloftDisplay(_ minutes: Int) -> String { + let days = minutes / (60 * 24) + let hours = (minutes % (60 * 24)) / 60 + if days > 0 { + return "\(days)d \(hours)h" + } + return "\(hours)h" + } + + private func timeAloftSubtitle(_ minutes: Int) -> String { + if minutes <= 0 { return "—" } + return "≈ \(numberString(minutes / 60)) total hours airborne" + } + + private func shortDate(_ d: Date) -> String { + let f = DateFormatter() + f.dateFormat = "MMM d, yyyy" + return f.string(from: d) + } +} diff --git a/Flights/Views/Styles/HistoryStyle.swift b/Flights/Views/Styles/HistoryStyle.swift new file mode 100644 index 0000000..f5cb303 --- /dev/null +++ b/Flights/Views/Styles/HistoryStyle.swift @@ -0,0 +1,127 @@ +import SwiftUI + +/// Design system scoped to the History tab. The rest of the app uses +/// `FlightTheme`; the redesigned passport-style history uses its own +/// warm aerospace palette so the visual identity doesn't bleed into +/// Search or Live. +/// +/// Palette commitment: +/// - Runway orange as identity color (vivid, hi-vis, aviation-coded) +/// - Midnight navy as the dark surface +/// - Warm cream paper as the light surface (passport-stock feel) +/// - Stamp green + foil gold as accents on dressed elements +enum HistoryStyle { + + // MARK: - Palette + + static let runwayOrange = Color(red: 1.00, green: 0.34, blue: 0.13) // #FF5722 + static let runwayOrangeDeep = Color(red: 0.85, green: 0.27, blue: 0.07) // #D9461A + static let runwayOrangeSoft = Color(red: 1.00, green: 0.55, blue: 0.35) // #FF8C59 + + static let midnightNavy = Color(red: 0.04, green: 0.08, blue: 0.14) // #0A1424 + static let inkNavy = Color(red: 0.08, green: 0.14, blue: 0.25) // #142440 + static let nightSky = Color(red: 0.06, green: 0.12, blue: 0.22) // #0F1E38 + + static let creamPaper = Color(red: 0.96, green: 0.93, blue: 0.85) // #F4ECD8 + static let creamPaperDeep = Color(red: 0.91, green: 0.87, blue: 0.77) // #E8DDC4 + static let creamPaperSoft = Color(red: 0.98, green: 0.96, blue: 0.91) // #FAF5E8 + + static let stampGreen = Color(red: 0.18, green: 0.35, blue: 0.24) // #2D5A3D + static let goldFoil = Color(red: 0.78, green: 0.66, blue: 0.32) // #C8A951 + + // MARK: - Adaptive surfaces (dark/light aware) + + /// Top-level page background. + static func background(_ scheme: ColorScheme) -> Color { + scheme == .dark ? midnightNavy : creamPaper + } + + /// Standard card / panel. + static func card(_ scheme: ColorScheme) -> Color { + scheme == .dark ? inkNavy : creamPaperSoft + } + + /// Secondary card (less prominence). + static func cardSubtle(_ scheme: ColorScheme) -> Color { + scheme == .dark ? nightSky : creamPaperDeep + } + + /// Primary text color on the background. + static func ink(_ scheme: ColorScheme) -> Color { + scheme == .dark ? Color(red: 0.96, green: 0.93, blue: 0.85) : Color(red: 0.06, green: 0.10, blue: 0.18) + } + + static func inkSecondary(_ scheme: ColorScheme) -> Color { + scheme == .dark ? Color.white.opacity(0.65) : Color.black.opacity(0.55) + } + + static func inkTertiary(_ scheme: ColorScheme) -> Color { + scheme == .dark ? Color.white.opacity(0.4) : Color.black.opacity(0.35) + } + + static func hairline(_ scheme: ColorScheme) -> Color { + scheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08) + } + + // MARK: - Hero card gradients + + static let heroOrangeGradient = LinearGradient( + colors: [runwayOrange, runwayOrangeDeep], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + + static let heroNavyGradient = LinearGradient( + colors: [inkNavy, midnightNavy], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + + static let heroGoldGradient = LinearGradient( + colors: [goldFoil, Color(red: 0.55, green: 0.46, blue: 0.20)], + startPoint: .top, endPoint: .bottom + ) + + static let heroGreenGradient = LinearGradient( + colors: [stampGreen, Color(red: 0.10, green: 0.22, blue: 0.15)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + + // MARK: - Typography + + /// Display weight used for hero numbers like "47,200 mi". + static func displayNumber(_ size: CGFloat) -> Font { + .system(size: size, weight: .heavy, design: .default) + .monospacedDigit() + } + + static func label(_ size: CGFloat = 11) -> Font { + .system(size: size, weight: .semibold, design: .default) + } + + /// OCR-passport flavor text font — monospaced, slightly condensed feel. + static let ocrFont: Font = .system(size: 11, weight: .regular, design: .monospaced) + + static let cardTitleFont: Font = .system(size: 13, weight: .semibold, design: .default) +} + +// MARK: - View modifiers + +extension View { + /// Bevel-style card chrome used across history surfaces. + func historyCard(_ scheme: ColorScheme, padding: CGFloat = 16, cornerRadius: CGFloat = 18) -> some View { + self + .padding(padding) + .background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: cornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(HistoryStyle.hairline(scheme), lineWidth: 0.5) + ) + } + + /// Tracking + uppercase wrapping for section labels and "FLIGHTS" etc. + func historyLabel() -> some View { + self + .font(HistoryStyle.label()) + .tracking(1.2) + .textCase(.uppercase) + } +} diff --git a/Flights/Views/YearInReviewView.swift b/Flights/Views/YearInReviewView.swift index ad38e63..b0cd18d 100644 --- a/Flights/Views/YearInReviewView.swift +++ b/Flights/Views/YearInReviewView.swift @@ -1,111 +1,211 @@ 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. +/// Year in Review — horizontal-paged deck of share-ready hero cards +/// for the chosen year. Each card is a full-screen composition: huge +/// stat number, small subtitle, footer brand mark. struct YearInReviewView: View { let stats: StatsEngine let year: Int @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var scheme 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" - ) - } + NavigationStack { + TabView { + coverCard(year: year, flights: yearFlights.count) + if yearStats.totalMiles > 0 { + distanceCard(yearStats) + } + airportsCard(yearStats) + hoursCard(yearStats) + if let top = yearStats.topAirline { + topAirlineCard(top) + } + if let route = yearStats.topRoute { + topRouteCard(route) + } + if let longest = yearStats.longestFlight { + longestCard(longest, yearStats: yearStats) } - .padding(.horizontal, 16) } - .padding(.vertical, 24) - .background(FlightTheme.background.ignoresSafeArea()) - .navigationTitle("Your \(year)") + .tabViewStyle(.page(indexDisplayMode: .always)) + .background(HistoryStyle.midnightNavy.ignoresSafeArea()) + .navigationTitle("\(year) Year in Flight") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Done") { dismiss() } + Button { dismiss() } label: { Image(systemName: "xmark") } } } } } + // MARK: - Card variants + 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)) + HeroComposition(background: HistoryStyle.heroOrangeGradient) { + VStack(spacing: 12) { + Spacer() + Text("\(year)") + .font(.system(size: 140, weight: .black).monospacedDigit()) + .foregroundStyle(.white) + Text("YEAR IN FLIGHT") + .font(.system(size: 18, weight: .heavy)) + .tracking(2.5) + .foregroundStyle(.white.opacity(0.85)) + Spacer() + Text("\(flights) flights logged") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + } } - .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)) + private func distanceCard(_ s: StatsEngine) -> some View { + HeroComposition(background: HistoryStyle.heroNavyGradient) { + cardBody( + eyebrow: "DISTANCE", + hero: s.shortDistance, + heroAccent: "mi", + subtitle: equatorBlurb(miles: s.totalMiles) + ) } - .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) + } + + private func airportsCard(_ s: StatsEngine) -> some View { + HeroComposition(background: HistoryStyle.heroGreenGradient) { + cardBody( + eyebrow: "PASSPORT STAMPS", + hero: "\(s.uniqueAirports)", + heroAccent: "airports", + subtitle: "\(s.uniqueCountries) countries" + ) + } + } + + private func hoursCard(_ s: StatsEngine) -> some View { + HeroComposition(background: HistoryStyle.heroGoldGradient) { + cardBody( + eyebrow: "TIME ALOFT", + hero: hoursDisplay(s.totalMinutes), + heroAccent: "", + subtitle: "≈ \(s.totalMinutes / 60) hours airborne" + ) + } + } + + private func topAirlineCard(_ top: (icao: String, count: Int)) -> some View { + let name = AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao + return HeroComposition(background: HistoryStyle.heroOrangeGradient) { + cardBody( + eyebrow: "TOP AIRLINE", + hero: name, + heroAccent: "", + subtitle: "\(top.count) flights" + ) + } + } + + private func topRouteCard(_ route: (label: String, count: Int)) -> some View { + HeroComposition(background: HistoryStyle.heroNavyGradient) { + cardBody( + eyebrow: "TOP ROUTE", + hero: route.label.replacingOccurrences(of: "↔", with: " ↔ "), + heroAccent: "", + subtitle: "\(route.count) trips" + ) + } + } + + private func longestCard(_ longest: LoggedFlight, yearStats: StatsEngine) -> some View { + let miles = yearStats.store.distanceMiles(for: longest) ?? 0 + return HeroComposition(background: HistoryStyle.heroGreenGradient) { + cardBody( + eyebrow: "ENDURANCE RECORD", + hero: "\(longest.departureIATA) → \(longest.arrivalIATA)", + heroAccent: "", + subtitle: "\(numberString(miles)) miles" + ) + } + } + + // MARK: - Card body template + + private func cardBody(eyebrow: String, hero: String, heroAccent: String, subtitle: String) -> some View { + VStack(spacing: 16) { + Spacer() + Text(eyebrow) + .font(.system(size: 14, weight: .heavy)) + .tracking(3) + .foregroundStyle(.white.opacity(0.8)) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(hero) + .font(.system(size: 64, weight: .black).monospacedDigit()) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.4) + .multilineTextAlignment(.center) + if !heroAccent.isEmpty { + Text(heroAccent) + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(.white.opacity(0.7)) + } + } + Text(subtitle) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + .multilineTextAlignment(.center) + Spacer() + Text("FLIGHTS · \(year)") + .font(.system(size: 10, weight: .heavy)) + .tracking(2) + .foregroundStyle(.white.opacity(0.55)) + } + .padding(20) + .multilineTextAlignment(.center) + } + + // MARK: - Helpers + + private func equatorBlurb(miles: Int) -> String { + let equator = 24_901 + let ratio = Double(miles) / Double(equator) + if ratio < 0.05 { return "miles flown" } + if ratio < 1 { return String(format: "%.0f%% of earth's equator", ratio * 100) } + return String(format: "%.1f× around the equator", ratio) + } + + private func hoursDisplay(_ minutes: Int) -> String { + let days = minutes / (60 * 24) + let hours = (minutes % (60 * 24)) / 60 + if days > 0 { return "\(days)d \(hours)h" } + return "\(hours)h" + } + + private func numberString(_ n: Int) -> String { + let f = NumberFormatter(); f.numberStyle = .decimal + return f.string(from: NSNumber(value: n)) ?? "\(n)" } } +/// Card frame for the Year in Review deck — generic background + +/// foreground content. Used to keep page-to-page motion + sizing +/// consistent. +private struct HeroComposition: View { + let background: LinearGradient + @ViewBuilder var content: () -> Content + + var body: some View { + ZStack { + background + content() + } + .clipShape(RoundedRectangle(cornerRadius: 28)) + .padding(.horizontal, 24) + .padding(.vertical, 32) + } +}