diff --git a/Flights/Views/HistoryRouteMapView.swift b/Flights/Views/HistoryRouteMapView.swift index 8f83a41..ff0fda5 100644 --- a/Flights/Views/HistoryRouteMapView.swift +++ b/Flights/Views/HistoryRouteMapView.swift @@ -32,7 +32,14 @@ struct HistoryRouteMapView: View { @State private var animationKey: Int = 0 @State private var schedule: AnimationSchedule = .empty @State private var selectedAirportSheet: AirportSheet? - @State private var sheetDetent: PresentationDetent = .fraction(0.14) + @State private var drawerExpanded: Bool = false + @State private var dragOffset: CGFloat = 0 + @State private var showingFilterSheet: Bool = false + + /// Drawer heights — peek shows just the row of stats + replay, + /// expanded shows filter chips + per-airport stats. + private static let drawerPeekHeight: CGFloat = 96 + private static let drawerExpandedHeight: CGFloat = 340 /// ~4 second total sweep, snappy. Per-flight slice is computed /// from this in `AnimationSchedule.build`. @@ -44,12 +51,29 @@ struct HistoryRouteMapView: View { } var body: some View { - ZStack { + ZStack(alignment: .bottom) { mapLayer + // Overlay drawer (not a sheet) — so the parent sheet's + // swipe-down-to-dismiss gesture still works on the map + // surface. The drawer has two states: peek and expanded. + // Tap or drag the handle to toggle; drag offset gives + // live feedback during the gesture. + mapDrawer + .frame(maxWidth: .infinity) + .frame(height: drawerHeight) + .offset(y: dragOffset) + .gesture(drawerDragGesture) } .navigationTitle(filters.isEmpty ? "Routes" : "Filtered Routes") .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showingFilterSheet = true } label: { + Image(systemName: filters.activeCount > 0 + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + } ToolbarItem(placement: .primaryAction) { Button { restart() } label: { Image(systemName: "arrow.clockwise.circle.fill") @@ -58,23 +82,14 @@ struct HistoryRouteMapView: View { } } } - // Recompute the schedule and the camera fit on first appear - // and whenever the filtered flight set changes. .onAppear { reset() } .onChange(of: filters) { _, _ in reset() } - // Drive the progress with a Task loop. Re-runs on animationKey - // bump (which restart() triggers). .task(id: animationKey) { await runAnimation() } - // The drawer is an always-presented sheet with detents — the - // user can drag it from peek up to large and back down. - .sheet(isPresented: .constant(true)) { - mapDrawer - .presentationDetents([.fraction(0.14), .medium, .large], selection: $sheetDetent) - .presentationBackgroundInteraction(.enabled) - .presentationDragIndicator(.visible) - .interactiveDismissDisabled() + .sheet(isPresented: $showingFilterSheet) { + HistoryFilterSheet(allFlights: allFlights, filters: $filters) + .presentationDetents([.medium, .large]) } .sheet(item: $selectedAirportSheet) { sheet in NavigationStack { @@ -91,6 +106,40 @@ struct HistoryRouteMapView: View { } } + private var drawerHeight: CGFloat { + drawerExpanded ? Self.drawerExpandedHeight : Self.drawerPeekHeight + } + + /// Drag gesture for the drawer handle area. + /// - Upward drag → expand + /// - Downward drag (from expanded) → collapse to peek + /// - dragOffset gives live feedback while the finger is down, + /// then snaps to a state on release with a spring. + private var drawerDragGesture: some Gesture { + DragGesture() + .onChanged { value in + let raw = value.translation.height + // Clamp the live offset so the drawer can't be dragged + // off-screen in either direction. + if drawerExpanded { + dragOffset = max(0, raw) + } else { + dragOffset = min(0, raw) + } + } + .onEnded { value in + let delta = value.translation.height + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + if drawerExpanded { + if delta > 60 { drawerExpanded = false } + } else { + if delta < -40 { drawerExpanded = true } + } + dragOffset = 0 + } + } + } + // MARK: - Map private var mapLayer: some View { @@ -111,9 +160,14 @@ struct HistoryRouteMapView: View { } } - // In-flight plane icons (only the segments currently animating) + // In-flight plane icons (only the segments currently animating). + // The plane scales 0 → 1 → 0 across its flight: takes off + // tiny, hits full size at the apex of the journey, lands + // small again. Reads like a real plane disappearing into + // the horizon and reappearing. ForEach(schedule.segments) { seg in if let head = seg.head(at: progress) { + let scale = seg.planeScale(at: progress) Annotation("", coordinate: head.coord) { Image(systemName: "airplane") .font(.system(size: 16, weight: .black)) @@ -122,6 +176,8 @@ struct HistoryRouteMapView: View { .background(HistoryStyle.runwayOrange, in: Circle()) .shadow(color: .black.opacity(0.4), radius: 3, y: 1) .rotationEffect(.degrees(head.bearing - 90)) + .scaleEffect(scale) + .opacity(scale) } .annotationTitles(.hidden) } @@ -152,13 +208,38 @@ struct HistoryRouteMapView: View { @ViewBuilder private var mapDrawer: some View { VStack(spacing: 0) { + drawerHandle drawerPeek - if sheetDetent != .fraction(0.14) { - Divider() - drawerExpanded + if drawerExpanded { + Divider().opacity(0.5) + drawerExpandedContent } Spacer(minLength: 0) } + .background( + UnevenRoundedRectangle( + topLeadingRadius: 22, + topTrailingRadius: 22 + ) + .fill(.regularMaterial) + .ignoresSafeArea(edges: .bottom) + ) + .shadow(color: .black.opacity(0.15), radius: 10, y: -2) + } + + private var drawerHandle: some View { + Capsule() + .fill(.gray.opacity(0.4)) + .frame(width: 36, height: 5) + .padding(.top, 8) + .padding(.bottom, 4) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + drawerExpanded.toggle() + } + } } private var drawerPeek: some View { @@ -204,7 +285,7 @@ struct HistoryRouteMapView: View { } @ViewBuilder - private var drawerExpanded: some View { + private var drawerExpandedContent: some View { VStack(alignment: .leading, spacing: 14) { if !filters.years.isEmpty || !filters.airlines.isEmpty || !filters.airports.isEmpty { Text("FILTERS") @@ -502,6 +583,15 @@ struct FlightSegment: Identifiable, Hashable { return Head(coord: here, bearing: bearing(from: here, to: next)) } + /// Plane scale across the flight — 0 at takeoff, 1.0 at the + /// midpoint, 0 at landing. Half-sine curve. Returns 0 when the + /// flight isn't currently animating. + func planeScale(at progress: Double) -> CGFloat { + guard progress >= startProgress, progress < endProgress else { return 0 } + let local = (progress - startProgress) / max(0.0001, endProgress - startProgress) + return CGFloat(sin(local * .pi)) + } + private func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double { let lat1 = a.latitude * .pi / 180 let lat2 = b.latitude * .pi / 180