From 572e81406d31998874ed5f16542871ec068699b9 Mon Sep 17 00:00:00 2001 From: Trey T Date: Fri, 29 May 2026 18:32:48 -0500 Subject: [PATCH] Route map fixes: swipe-dismiss, filter button, plane scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes the user called out: 1. Can't swipe down to dismiss the map screen anymore — the inner .sheet for the drawer was eating the parent sheet's swipe-to-dismiss gesture. Replaced with a custom overlay drawer built from a ZStack + DragGesture, so the map surface is now free for the parent sheet's interactive dismiss. Drawer has two snap states (peek + expanded), tap the handle or drag up to expand, drag down to collapse. Live dragOffset gives immediate finger feedback; on release we spring-snap to a state. 2. Filter button added to the map's toolbar. Opens the full HistoryFilterSheet so users can edit filters without going back to History. Icon switches to .fill variant when any filter is active. 3. Plane icons now scale 0 → 1 → 0 across each flight (half-sine curve) so they fade in at takeoff, peak at the midpoint, and fade out at landing. New FlightSegment.planeScale(at:) returns sin(localProgress * π). Scale is applied to both .scaleEffect and .opacity so it's a real "appear-and-vanish." Co-Authored-By: Claude Opus 4.7 --- Flights/Views/HistoryRouteMapView.swift | 128 ++++++++++++++++++++---- 1 file changed, 109 insertions(+), 19 deletions(-) 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