Skip to content

Design: flight detail popup as a summary page with drill-in

Moved from vulture-ios/Docs/flight-detail-summary-redesign.md

Status: implemented 2026-08-25 (commits below); the iPhone non-modal sheet (step 6) is still open. Originally designed 2026-08-25. Direction chosen from the design canvas at https://claude.ai/code/artifact/7c861511-976c-4edb-8fb7-bde7ba1860c9 (“Option A” — the Main artboard). This document is the hand-off for the implementation session: it records the decision, the target screen, how it maps onto the code that exists today, and the commit plan.

The flight and task detail popup — the panel the map’s info button opens — feels dated. Today it is a TabView inside a popover (iPad) or an adapted sheet (iPhone): the navigation title is the tab name (“Flight”), the root is a plain list of label/value rows, and five tabs sit in a bar at the bottom (Flight, Performance, Thermals, Phases, Info; the task popup has Task and Notes). Two things make it read as old:

  • A tab bar is app-level navigation in the HIG; inside a popover it is a container inside a container.
  • The root has no identity: it opens on a tab of rows, not on this flight. The long-press preview card in the list (SidebarListPreview, commit 90c9c304e) already shows the shape the root should have.

The pushed pages that exist (the “Declared task” optimization page, the airspace infringement page) already look current: hero map, metric tiles, timeline rows. The root should be built from the same vocabulary.

Option A — one scrolling summary page whose rows are the old tabs, each pushing its full page. The Fitness workout / Health data type / Maps place card pattern.

Chosen over:

  • B — pill row under the title, pages swipe. Cheapest change, but still “pages of rows”; it removes the tab bar without giving the root an identity.
  • C — keep the tabs, adopt the iOS 26 floating tab bar. The bar is already the iOS 26 glass one; the problem is structural, not chrome.

Two presentation changes are follow-ups, not part of this design:

  • D (iPhone) — present the popup as a non-modal sheet (medium/large detents, map undimmed) so tapping a phase or thermal centres the map without the sheet going away. Self-contained; can ship right after A.
  • D (iPad) — a persistent trailing inspector column instead of the popover anchored on the info button. This is a shell decision and belongs with the Notes-style shell work (Docs/notes-style-navigation-investigation.md).

Sequencing against the release plan (decided 2026-08-25): A fits a polish window after 3.4.0 ships, independent of Mapbox v11 and the shell swap. Nothing here touches Mapbox.

PieceWhereRole
DetailTabsControllerModules/App/Sources/Modules/Detail/UIHostingController<DetailTabsView>; renders the selected tab’s title, bar items and phase-filter title menu into the navigation item; persists the selected tab; pushes the waypoint picker; resolves DetailActions (share sheet, rename alert, report mail, dismiss).
DetailTabBarViewModelsameBuilds the tab view models for a flight (5) or a task (2); snapshots CoreFlight into DetailFlightRecord — the Core Data boundary. Persists the tab in Settings.flightDetailsSelectedTab.
DetailTabBridgesameOne per tab: turns the tab’s value descriptions into interactions — row taps, swipe actions, context menus — and pushes OptimizationDetailHostingController / AirspaceInfringementsHostingController. Saves and restores scroll offset per tab.
DetailTabsView / DetailTabsModelPackages/Scenes/…/FlightDetail/The SwiftUI TabView, one DetailTabContentView per tab.
DetailTabContentView / DetailTabContentModelsameOne tab’s sections as a List, with the glass-compositing background rules documented in the view.
DetailTabConvertible + DetailTab{Flight,Performance,Thermals,Phases,Info,Task,TaskInfo}ViewModelsameValue-typed tab content: sections, barItems, selection, action publisher. Tested in ScenesTests.
DetailMetricGrid / DetailMetricsameThe two-column hairline metric tiles (used by the task pages and the list preview).
DetailPhaseRow, DetailInfoRow, DetailTurnpointRow, DetailGraphRowsameRow content.
TrackThumbnailTile, TrackShapeView, RowTrackShapePackages/Scenes/…/Components/Accent-stroke track thumbnail on tertiary fill, from Packages/TrackShape.
SidebarListPreviewPackages/Scenes/…/FlightList/The long-press card: glider, four DetailMetrics, comment — the closest existing thing to the new root.
OptimizationDetailViewModules/App/Sources/Scenes/OptimizationDetail/The pushed “Declared task” / optimization page (hero map, metric tiles, legs). Still in the app module — it owns an OptimizationMapView (Mapbox).

Presentation: FlightMapViewController.presentInfo(_:) wraps the controller in a UINavigationController, modalPresentationStyle = .popover anchored on the info bar button; on iPhone UIKit adapts it to a sheet.

┌────────────────────────────────────┐
│ (…) 25 Jun 2025 (×) │ title = date; subtitle = glider · comp id
│ Ventus 3T · EY │
├────────────────────────────────────┤
│ ┌────────────────────────────────┐ │
│ │ track thumbnail (cached shape)│ │ card: thumbnail + identity
│ │ Sint-Truiden AAT │ │
│ │ Thursday 25 June 2025 · 13:45 │ │
│ └────────────────────────────────┘ │
│ ┌───────────────┬────────────────┐ │
│ │ Distance │ Duration │ │ DetailMetricGrid, the same four
│ │ 448.95 km │ 04:32:11 │ │ metrics as SidebarListPreview
│ ├───────────────┼────────────────┤ │
│ │ Average speed │ Height gain │ │
│ │ 107 km/h │ 8 320 m │ │
│ └───────────────┴────────────────┘ │
│ Analysis │ section header (20 pt semibold, as "Legs")
│ ┌────────────────────────────────┐ │
│ │ ◉ Declared task 448.95 km › │ │ row: 32 pt tinted badge, title,
│ │ AAT 3h · 4 turnpoints │ │ caption, trailing headline, chevron
│ │ ◉ Performance 36.4 › │ │
│ │ ◉ Thermals 2.1 m/s › │ │
│ │ ◉ Phases 41 › │ │
│ │ ◉ Flight › │ │
│ │ ◉ Info › │ │
│ └────────────────────────────────┘ │
│ Remarks │ only when the record has comments
│ ┌────────────────────────────────┐ │
│ │ Strong convergence over the … │ │
│ └────────────────────────────────┘ │
└────────────────────────────────────┘
  • Title: the flight date (short form, “25 Jun 2025”); subtitle: glider type and competition id from the DetailFlightRecord, via UINavigationItem.subtitle.
  • Trailing (not leading, as first drafted): one ellipsis menu holding the flight-wide action the tabs each carried — report a problem. The leading slot stays free because the adaptive popover delegate puts the close button there on iPhone. DetailBarItem.report and the four flight pages’ barItems are gone; a page renders only bar items of its own (the task page’s export menu).
  • No tab bar. DetailTabsView, DetailTabsModel, DetailTab and DetailTabIcon are deleted — with no tab bar, nothing renders an icon.
  • TrackShapeView at card width (~170 pt tall), accent stroke, start dot, on tertiarySystemFill. The view takes Scenes’ own RowTrackShape (normalized points + aspect ratio); the app builds it — as the list rows do — from the cached CoreFlight.trackPreview blob (SimplifiedTrack from Packages/TrackShape) when present, else from Flight.fixes via TrackSimplifier at open time (the flight is already in memory). Scenes does not depend on TrackShape; keep the conversion app-side. Tasks: shape from waypoints, as the task rows do.
  • Identity lines: declared task name (or takeoff location when no task), full date, takeoff–landing times.

Reuse DetailMetricGrid with exactly the four metrics SidebarListPreview.flightMetrics builds, so the preview card and the detail root agree:

TileExisting keySource
Distanceflight.statistics.data.optimized.distanceoptimized distance
Durationflight.statistics.data.durationflight.duration
Average speedflight.info.label.cruise.speedflight.averageCruisingSpeed
Height gainflight.info.label.altitudegainedaltitude gained

Drop tiles the flight has no value for (the grid already handles an odd count).

The row is titled flight.task.info.header (“Declared task”) — the label the page it opens carries, not flight.label.optimization.task (“Optimized distance”), which is what the same task is called as a row inside the performance page. Since the summary opens the task directly, the performance page dropped its own declared-task section rather than showing it twice one tap apart (2026-08-25).

That section was the only place the flight popup reported whether the task counted, so the row leads with the status rather than with a figure — the first thing a pilot looks for — and keeps its meaning in colour as the old row did: Tint.success green when completed, accent when not. The declared distance sits beneath it; what was actually flown is on the page the row opens.

Each row is badge + title + caption + trailing headline + chevron, the row anatomy from CLAUDE.md’s design conventions (DetailPhaseRow is the model — reuse its badge and value column, or extract a shared DetailSummaryRow). Tints: task and phases Tint Secondary blue, performance accent, thermals Thermal green, flight/info neutral grey.

RowCaptionHeadlinePushesData already computed by
Declared taskturnpointscompleted / unfinished, with the declared distance beneathOptimizationDetailHostingController for the declared task — not a tab pageflight.task + flight.bestTaskAttempt
Performancebest optimization namethat optimization’s distance, with its speed beneathPerformance page (achievements + optimizations)DetailTabPerformanceViewModel
Thermalscount · % of flightaverage climbThermals pageDetailTabThermalsViewModel (thermal.total, thermal.average, thermal.percentage)
Phasescruises · climbs · eventsphase countPhases page (keeps the phase-filter title menu)DetailTabPhasesViewModel
Flighttakeoff type · average wind— (or airspace warning, below)Flight page (durations, takeoff, wind, water, airspace, cruise, flaps graph)DetailTabFlightViewModel
Infopilot · glider—Info page (takeoff, glider, equipment)DetailTabInfoViewModel

Rows are omitted when their page would be empty (no declared task, no thermals on a winch hop).

Airspace infringements are a signal, not a page. When airspaceInfringements is non-empty, the Flight row’s headline shows the count with the warning tint and the row’s caption says “n infringements”; tapping the row still opens the Flight page (the infringement row there pushes the detail, as today). Consider promoting it to its own row if it gets lost — decide on device.

Altitude sparkline row (the canvas shows one): a stretch item. It needs a new small chart from Flight.fixes; the existing DetailGraphRow is a bar chart for flaps/thermal buckets, not a line. Leave out of the first pass.

If DetailFlightRecord.comments is non-empty, a “Remarks” section with the full text as a card (the list preview already shows the full comment; the Flight page keeps its remarks row for editing parity).

The task popup has two tabs, Task and Notes. Under the new model the Task page is the root (it already opens with a hero map and metric tiles), and Notes becomes a row at the bottom of it pushing the notes page. No summary page is needed for tasks; only the tab bar goes.

Implemented differently (2026-08-25): the notes did not become a pushed row — they were merged into the task page as its last two sections (inactive airspace, then notes). A pushed page for two short text sections is over-navigation, and merging deleted a whole type (DetailTabTaskInfoViewModel) plus its duplicate export/share menu, with no new row vocabulary. The task popup is now literally one page.

Follows the Scenes package rules (MVVM, @Observable, per-screen protocol, mock, tests, no .shared, NSManagedObject never crosses).

New in Packages/Scenes/Sources/Scenes/Screens/FlightDetail/

Section titled “New in Packages/Scenes/Sources/Scenes/Screens/FlightDetail/”
  • DetailSummaryViewModel — @Observable @MainActor, public init(flight: Flights.Flight, record: DetailFlightRecord?, recordDidChange:, trackShape: RowTrackShape?, settings: DetailSettingsProviding). Builds the hero identity, the four DetailMetrics, and [DetailSummaryRow] values. It does not own the tab view models; the row headlines are derived from the same Flight values the tabs use — extract the shared computations into small Flight extensions in Scenes if the tabs currently inline them, so the summary and the pages cannot disagree.
  • DetailSummaryRow — value type: kind (an enum the container maps to a destination), title, caption, headline, headlineDetail, tint, systemImageName.
  • DetailSummaryView — the SwiftUI screen; pure layout, #Preview at the bottom with a mock flight from the Flights fixtures.
  • DetailSummaryRowView — shared row component, or a generalisation of DetailPhaseRow if the anatomy matches closely enough.
  • DetailSummaryViewModelTests — headline/caption per row from a fixture flight; rows omitted when empty; record change refreshes remarks and the subtitle; metric parity with SidebarListPreview.

Renames landed with the swap: DetailTabsController → DetailPopupController, DetailTabBarViewModel → DetailPopupViewModel. Three types are new: DetailPopupRoot (the protocol the map subscribes through, since the flight popup’s root is the summary and the task popup’s is a page), DetailPopupActionPresenter (extracted first, so the summary root and each page resolve share sheets and alerts only while they are on top), and DetailTabPageController.

  • DetailPopupController (was DetailTabsController) is the root: hosts DetailSummaryView, owns the navigation item (date title, ellipsis menu, close), and resolves onSelect(row) to a push:
    • .declaredTask → the existing optimization push (lift the code from DetailTabBridge.select(optimization:)).
    • .performance/.thermals/.phases/.flight/.info → a new DetailTabPageController: UIHostingController<DetailTabContentView> that hosts one DetailTabBridge. The page controller takes over what invalidateHeader() does today for one tab: title = tab title, right bar items = barItems, titleMenuProvider = the phase selection menu when selection != nil. Scroll memory (saveScrollOffset / initialScrollOffset) stays in the bridge and keeps working per page.
  • DetailTabBarViewModel keeps building the tab view models (they are now page view models); initialTabIndex/switchSection go away.
  • DetailTabBridge is unchanged except that its host is the page controller rather than the tabs controller. The action stream and the map-side resolution (FlightMapViewController.subscribe(controller:)) keep working because the summary root still exposes the merged publisher.
  • DetailTabsView, DetailTabsModel, DetailTab, DetailTabIcon.image for the tab bar are deleted once nothing references them (mint run periphery scan after the swap).

Settings.flightDetailsSelectedTab — a plain local Defaults key in Eagle (Defaults+Vulture.swift, VultureFlightDetailsSelectedTab; not part of the iCloud sync surface) — loses its meaning. Options: (a) drop it — the popup always opens on the summary, which is the point of the redesign; (b) remember the last pushed page and re-push it on open. Recommend (a); a remembered deep page defeats the summary and the push animation on open looks like a glitch. Delete the key, the Settings accessor and the update method in the same commit; nothing else reads them.

New keys — every one in all 8 locales (cs, de, en, es, fr, it, nl, pl), verified with the jq completeness check in CLAUDE.md, and covered by the locale test:

  • section header “Analysis”; “Remarks” exists (flight.info.label.remarks).
  • row captions: “%d turnpoints”, “%d thermals”, “%d cruises · %d climbs”, “%d infringements”.
  • the ellipsis menu title if any.

Row titles reuse the tab titles (flight.tab.label.*); tile labels reuse the keys above. Dutch: tasks are opdrachten, not taken.

  • Backdrop: paint nothing. .scrollContentBackground(horizontalSizeClass == .regular ? .hidden : .automatic), no replacement .background(...), and no .listRowBackground on ordinary rows — the pattern OptimizationDetailView and AirspaceInfringementsView use. The controllers guard view.backgroundColor = .clear the same way. Any explicit fill covers the glass the popover draws; hiding the backdrop unconditionally costs iPhone its inset-grouped look.

    A workaround in aad5e4c2f did paint explicit fills, because the SwiftUI TabView vibrancy-dissolved backdrop-derived colours into a grey wash. Deleting the tab bar removed that cause, so the workaround was reverted along with it (2026-08-25) — the pages are now hosted exactly as the optimization page is. Verify light and dark on iPad and iPhone.

  • Row anatomy per CLAUDE.md: title .body, caption .caption secondary monospacedDigit(), headline trailing semibold monospacedDigit(), 32 pt tinted circle badge.

  • Dynamic Type: the metric grid already collapses to one column at accessibility sizes; the summary row must let the headline wrap under the title at accessibility sizes (AnyLayout switch).

  • Every row gets an accessibilityLabel combining title, caption and headline.

  • Popover on iPad stays glassy — never paint a grouped backdrop under it.

Shipped 2026-08-25 as four commits (2 and 3 of the original plan merged: the row component and the screen are one design unit, and neither is useful without the other). Step 6 — the iPhone non-modal sheet — is still open.

One logical change per commit, checkpoint after each (device build, Scenes

  • Waypoints + Airspace tests, SwiftLint/swift-format at zero; Flights only when Flights changes).
  1. refactor: extract the page controller — DetailTabPageController hosting one bridge with its title, bar items and selection menu. The TabView still exists at this point; this commit only proves a tab can live as a pushed page (push one from a temporary debug row, or land it dormant).
  2. feat: DetailSummaryViewModel + DetailSummaryRow + tests + preview in Scenes. No UI wiring yet; shippable dormant.
  3. feat: DetailSummaryView + row component, previewed against the fixture flight in light/dark and at accessibility-extra-large.
  4. feat: swap the root — DetailTabsController hosts the summary and pushes pages; task popup drops its tab bar and gains the Notes row; delete DetailTabsView/DetailTabsModel; drop flightDetailsSelectedTab. Device pass: iPhone sheet, iPad popover, Mac (Designed for iPad), light/dark, phase filter menu on the Phases page, share anchor on the task page, scroll restore per page, report mail from the ellipsis menu, .dismiss-after-centre behaviour.
  5. style:/feat: native polish pass — not needed as a separate commit: the working agreement separates polish from a move so the move diff proves behaviour, and nothing moved here. The summary is a new screen, designed against the conventions from the start and verified with simulator probe screenshots in light and dark.
  6. feat: D (iPhone) as its own change: UISheetPresentationController with .medium/.large detents, prefersGrabberVisible, largestUndimmedDetentIdentifier = .medium; and stop sending .dismiss after centerFix/highlightFixes when the sheet is non-modal — collapse to medium instead. Keep the popover on iPad.

CHANGELOG.md under [Unreleased] → Changed: “The flight detail now opens on a summary of the flight — track, key figures, and one row per analysis page — instead of a row of tabs.” Flag for the App Store release notes; users will notice.

  • Subtitle in the navigation bar inside a popover: UINavigationItem.subtitle (iOS 26) is wired; still needs the device pass to confirm it renders inside the popover. Fallback is a single composed title.
  • Which figure is the Performance headline? Settled (2026-08-25) as the furthest optimization’s distance, with its average speed beneath — the same pair, in the same order, as the declared task’s row, and the pair the caption (“Free 5 turnpoint distance”) names. The glide ratio it first carried as the detail is gone.
  • Remember-last-page (persistence option b) if the summary turns out to cost thermals/phases users too many taps — measure with the on-device pass before adding it.
  • Whether the airspace signal deserves its own row. It takes the flight row over instead: when the calculation reports crossings, the row’s badge becomes a red exclamationmark.triangle.fill and its caption the already-translated “%u Infringements”, with no headline — showing the count twice (caption and margin) read as noise in the probe.
  • The altitude sparkline row (stretch).
  • Where OptimizationDetailView lives: it stays in the app module until Mapbox v11 (it owns a map view); the summary pushes it through the existing hosting controller, no change needed.
Modules/App/Sources/Modules/Detail/DetailPopupController.swift (the summary root)
Modules/App/Sources/Modules/Detail/DetailTabPageController.swift (one pushed page; also the task popup's root)
Modules/App/Sources/Modules/Detail/DetailPopupActionPresenter.swift (share sheet, alerts, picker — for whichever screen is on top)
Modules/App/Sources/Modules/Detail/DetailPopupRoot.swift
Modules/App/Sources/Modules/Detail/DetailPopupViewModel.swift
Modules/App/Sources/Modules/Detail/DetailTabBridge.swift
Modules/App/Sources/Modules/Detail Map/Flight/FlightMapViewController.swift (presentInfo, subscribe(controller:))
Modules/App/Sources/Scenes/OptimizationDetail/ (the pushed "Declared task" page)
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailSummaryView.swift (the screen)
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailSummaryViewModel.swift
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailSummaryRow.swift (+ DetailSummaryRowView)
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailFlightFigures.swift (judged distance/speed/duration, takeoff type)
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailTabContentView.swift (page content)
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailTabConvertible.swift (DetailAction, tab protocol)
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailTab*ViewModel.swift (become page view models)
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailMetricGrid.swift
Packages/Scenes/Sources/Scenes/Screens/FlightDetail/DetailPhaseRow.swift (row anatomy to reuse)
Packages/Scenes/Sources/Scenes/Screens/FlightList/SidebarListPreview.swift (metric parity)
Packages/Scenes/Sources/Scenes/Components/TrackThumbnailTile.swift
Packages/TrackShape/ (SimplifiedTrack, TrackSimplifier)