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, commit90c9c304e) 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.
Decision
Section titled “Decision”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.
What exists today (inventory 2026-08-25)
Section titled “What exists today (inventory 2026-08-25)”| Piece | Where | Role |
|---|---|---|
DetailTabsController | Modules/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). |
DetailTabBarViewModel | same | Builds 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. |
DetailTabBridge | same | One 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 / DetailTabsModel | Packages/Scenes/…/FlightDetail/ | The SwiftUI TabView, one DetailTabContentView per tab. |
DetailTabContentView / DetailTabContentModel | same | One 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}ViewModel | same | Value-typed tab content: sections, barItems, selection, action publisher. Tested in ScenesTests. |
DetailMetricGrid / DetailMetric | same | The two-column hairline metric tiles (used by the task pages and the list preview). |
DetailPhaseRow, DetailInfoRow, DetailTurnpointRow, DetailGraphRow | same | Row content. |
TrackThumbnailTile, TrackShapeView, RowTrackShape | Packages/Scenes/…/Components/ | Accent-stroke track thumbnail on tertiary fill, from Packages/TrackShape. |
SidebarListPreview | Packages/Scenes/…/FlightList/ | The long-press card: glider, four DetailMetrics, comment — the closest existing thing to the new root. |
OptimizationDetailView | Modules/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.
Target screen
Section titled “Target screen”┌────────────────────────────────────┐│ (…) 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 … │ ││ └────────────────────────────────┘ │└────────────────────────────────────┘Navigation bar
Section titled “Navigation bar”- Title: the flight date (short form, “25 Jun 2025”); subtitle: glider type
and competition id from the
DetailFlightRecord, viaUINavigationItem.subtitle. - Trailing (not leading, as first drafted): one
ellipsismenu 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.reportand the four flight pages’barItemsare gone; a page renders only bar items of its own (the task page’s export menu). - No tab bar.
DetailTabsView,DetailTabsModel,DetailTabandDetailTabIconare deleted — with no tab bar, nothing renders an icon.
Hero card
Section titled “Hero card”TrackShapeViewat card width (~170 pt tall), accent stroke, start dot, ontertiarySystemFill. The view takes Scenes’ ownRowTrackShape(normalized points + aspect ratio); the app builds it — as the list rows do — from the cachedCoreFlight.trackPreviewblob (SimplifiedTrackfromPackages/TrackShape) when present, else fromFlight.fixesviaTrackSimplifierat 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.
Metric tiles
Section titled “Metric tiles”Reuse DetailMetricGrid with exactly the four metrics
SidebarListPreview.flightMetrics builds, so the preview card and the
detail root agree:
| Tile | Existing key | Source |
|---|---|---|
| Distance | flight.statistics.data.optimized.distance | optimized distance |
| Duration | flight.statistics.data.duration | flight.duration |
| Average speed | flight.info.label.cruise.speed | flight.averageCruisingSpeed |
| Height gain | flight.info.label.altitudegained | altitude gained |
Drop tiles the flight has no value for (the grid already handles an odd count).
Analysis rows
Section titled “Analysis rows”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.
| Row | Caption | Headline | Pushes | Data already computed by |
|---|---|---|---|---|
| Declared task | turnpoints | completed / unfinished, with the declared distance beneath | OptimizationDetailHostingController for the declared task — not a tab page | flight.task + flight.bestTaskAttempt |
| Performance | best optimization name | that optimization’s distance, with its speed beneath | Performance page (achievements + optimizations) | DetailTabPerformanceViewModel |
| Thermals | count · % of flight | average climb | Thermals page | DetailTabThermalsViewModel (thermal.total, thermal.average, thermal.percentage) |
| Phases | cruises · climbs · events | phase count | Phases page (keeps the phase-filter title menu) | DetailTabPhasesViewModel |
| Flight | takeoff type · average wind | — (or airspace warning, below) | Flight page (durations, takeoff, wind, water, airspace, cruise, flaps graph) | DetailTabFlightViewModel |
| Info | pilot · 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.
Remarks
Section titled “Remarks”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).
Task popup
Section titled “Task popup”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.
Architecture
Section titled “Architecture”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 fourDetailMetrics, and[DetailSummaryRow]values. It does not own the tab view models; the row headlines are derived from the sameFlightvalues the tabs use — extract the shared computations into smallFlightextensions 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,#Previewat the bottom with a mock flight from the Flights fixtures.DetailSummaryRowView— shared row component, or a generalisation ofDetailPhaseRowif 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 withSidebarListPreview.
Changed in the app module
Section titled “Changed in the app module”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(wasDetailTabsController) is the root: hostsDetailSummaryView, owns the navigation item (date title, ellipsis menu, close), and resolvesonSelect(row)to a push:.declaredTask→ the existing optimization push (lift the code fromDetailTabBridge.select(optimization:))..performance/.thermals/.phases/.flight/.info→ a newDetailTabPageController: UIHostingController<DetailTabContentView>that hosts oneDetailTabBridge. The page controller takes over whatinvalidateHeader()does today for one tab: title = tab title, right bar items =barItems,titleMenuProvider= the phase selection menu whenselection != nil. Scroll memory (saveScrollOffset/initialScrollOffset) stays in the bridge and keeps working per page.
DetailTabBarViewModelkeeps building the tab view models (they are now page view models);initialTabIndex/switchSectiongo away.DetailTabBridgeis unchanged except that itshostis the page controller rather than the tabs controller. Theactionstream and the map-side resolution (FlightMapViewController.subscribe(controller:)) keep working because the summary root still exposes the merged publisher.DetailTabsView,DetailTabsModel,DetailTab,DetailTabIcon.imagefor the tab bar are deleted once nothing references them (mint run periphery scanafter the swap).
Persistence
Section titled “Persistence”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.
Strings
Section titled “Strings”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.
Design rules to keep
Section titled “Design rules to keep”-
Backdrop: paint nothing.
.scrollContentBackground(horizontalSizeClass == .regular ? .hidden : .automatic), no replacement.background(...), and no.listRowBackgroundon ordinary rows — the patternOptimizationDetailViewandAirspaceInfringementsViewuse. The controllers guardview.backgroundColor = .clearthe same way. Any explicit fill covers the glass the popover draws; hiding the backdrop unconditionally costs iPhone its inset-grouped look.A workaround in
aad5e4c2fdid paint explicit fills, because the SwiftUITabViewvibrancy-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.captionsecondarymonospacedDigit(), headline trailing semiboldmonospacedDigit(), 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 (
AnyLayoutswitch). -
Every row gets an
accessibilityLabelcombining title, caption and headline. -
Popover on iPad stays glassy — never paint a grouped backdrop under it.
Commit plan
Section titled “Commit plan”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).
refactor:extract the page controller —DetailTabPageControllerhosting one bridge with its title, bar items and selection menu. TheTabViewstill 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).feat:DetailSummaryViewModel+DetailSummaryRow+ tests + preview in Scenes. No UI wiring yet; shippable dormant.feat:DetailSummaryView+ row component, previewed against the fixture flight in light/dark and at accessibility-extra-large.feat:swap the root —DetailTabsControllerhosts the summary and pushes pages; task popup drops its tab bar and gains the Notes row; deleteDetailTabsView/DetailTabsModel; dropflightDetailsSelectedTab. 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.— 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.style:/feat:native polish passfeat:D (iPhone) as its own change:UISheetPresentationControllerwith.medium/.largedetents,prefersGrabberVisible,largestUndimmedDetentIdentifier = .medium; and stop sending.dismissaftercenterFix/highlightFixeswhen 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.
Open questions
Section titled “Open questions”- 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 redexclamationmark.triangle.filland 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
OptimizationDetailViewlives: 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.
Key files
Section titled “Key files”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.swiftModules/App/Sources/Modules/Detail/DetailPopupViewModel.swiftModules/App/Sources/Modules/Detail/DetailTabBridge.swiftModules/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.swiftPackages/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.swiftPackages/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.swiftPackages/TrackShape/ (SimplifiedTrack, TrackSimplifier)