Skip to content

02: Flight screens UX ("analyse flights"): iOS behaviour spec and Android/Compose plan

Moved from vulture-android/docs/analysis/02-flight-screens-ux.md

Scope. Every screen and flow a pilot uses to get flights in, browse them and analyse them. Not in scope: task planning, the in-flight map, weather, airspace files and settings, apart from where they touch flights.

Source. /Users/fousa/Projects/vulture-ios, read-only. Paths are relative to it unless absolute.

Branch state. The two-column UISplitViewController shell (refactor/sidebar), 2026-10.

Related docs. 01-flight-domain.md (analysis algorithms), 03-persistence-settings-sync.md (Core Data / prefs / Pro), 04-integrations.md (WeGlide, SkyLines, Soaring Spot, Charron, BLE, FLARM), 05-map-baro-airspace.md (Mapbox layers and barogram internals). This document is about UX behaviour: what the user sees, which action triggers what, labels, states. Where it overlaps the others, they win on algorithms and this one wins on UI behaviour.

  1. Key findings and parity traps
  2. Sources read and screenshots
  3. App shell and navigation (+ Android mapping)
  4. Flight list (Logbook / Recents / gallery / filter / search / select / statistics)
  5. Getting flights in (import)
  6. Flight map screen: map, barogram, replay, compare, ranking, optimisation
  7. Flight detail panels, thermal detail, edit, export/upload, Debrief, rating, photos
  8. Strings and localisation
  9. Screen inventory: iOS → Compose screen / ViewModel / M3 Expressive components
  10. iOS 26 Liquid Glass and UIKit idioms → Android-native equivalents
  11. Risks, open decisions and size estimate

  1. Shell.
    • The shell is a sidebar plus one detail column, not list-detail.
    • The list and an opened flight are never side by side: the flight map is pushed over the list, and opening another flight replaces it.
    • Android should mirror this with a nested NavHost per destination. A ListDetailPaneScaffold would be a visible deviation.
  2. The flight “detail” is the map.
    • Opening a flight opens a full-screen map with a floating bottom panel: flight chips, barogram and play controls.
    • The statistics live in a separate detail popup behind the i button: a sheet on phones, a popover on iPad, an inspector column on Mac.
    • That popup contains a summary page plus 7 drill-down pages (§6).
  3. The default flight-list layout is the gallery, not the list (map-snapshot cards).
  4. No swipe actions exist on flight rows. Everything is a context menu.
  5. IGC is the only flight format. There is no GPX/KML import and no KML/GPX export. Export is IGC + “Map Image” PNG + uploads (SkyLines, WeGlide, Charron).
  6. Two import outcomes (§4.0):
    • Import into the logbook: silent, nothing opens, and the flights arrive unread.
    • Open on the map without saving: the flight goes to Recents and can be saved later.
    • Duplicate imports are silently de-duplicated by identical IGC text.
  7. Comparing flights is not a separate screen. It means “several flights on one map”. Up to 20 palette colours cycle, there is no legend and no visibility toggle. The Compare table is a modal opened from that map.
    • More than one flight requires Pro (.severalFlights).
  8. Only one barogram series is shown at a time, picked from a unit menu (default Pressure altitude). Thermals are drawn as vertical bands. The trace has no circling/gliding colouring.
  9. Track colouring. The track is coloured by altitude only (11-stop red→blue gradient), or plain.
  10. 3D does not exist (Docs/3d-map-investigation.md is investigation only). The only “3D” in the app is the thermal helix chart.
  11. Hidden defaults. The playback speed effectively defaults to 10×, because a missing Int key reads as rawValue 0. Replay speeds are 10/25/50/100/200×.
  12. Flight Coach (LLM chat) is built but disabled. Debrief is deterministic and ships.
  13. Localisation.
    • Strings live in two String Catalogs (.xcstrings, JSON): DesignSystem (957 keys) and App (510 keys). There are also small ones in Formatting, Preferences, InfoPlist and AppShortcuts.
    • 8 locales: en (source, US spelling), cs, de, es, fr, it, nl, pl. All are complete.
    • The catalogs are machine-convertible to Android strings.xml / plurals. Mind %@ → %s and positional args.

AreaWhere
Architecture / design conventionsCLAUDE.md § “SwiftUI architecture — the screen packages”, “Design conventions (iOS 26)”, “Keeping a List lazy”, “Hosting controller pitfalls”; LOCALIZATION_STYLE.md; Docs/hig-compliance.md
ShellModules/App/Sources/Shell/** (Split, Sidebar, Sidebar/List/**, Menus, DragAndDrop, Navigation)
Flight list UIPackages/FlightScreens/Sources/FlightScreens/FlightList/**, ListFilter/**, Statistics/**
ImportPackages/ImportScreens/**, Modules/App/Sources/Shell/Split/SplitViewController+Import.swift, SplitViewModel.swift, Application/SceneDelegate*.swift, Modules/App/Resources/Info.plist
Flight mapModules/App/Sources/Map/Flight/** (FlightMapViewController 2183 loc, FlightMapViewModel 1705 loc, Panel/Graph/** ≈ 3 000 loc), Map/OptimizationDetail/**
Detail popupModules/App/Sources/Features/Flight/**, Packages/FlightScreens/Sources/FlightScreens/FlightDetail/**, ThermalDetail/**, EditFlight/**, FlightDebrief/**, FlightRating/**, AssetPager/**, AirspaceInfringements/**
Compare / rankingPackages/FlightScreens/Sources/FlightScreens/{Compare,RaceRanking,Hosting/Compare,Hosting/RaceRanking}/**
Design docsDocs/flight-detail-summary-redesign.md, flight-detail-inspector-investigation.md, sport-specific-analysis.md, 3d-map-investigation.md, notes-style-navigation-investigation.md
Shared componentsPackages/DesignSystem/Sources/DesignSystem/{Components,Selection,Screens}/**

Files: fastlane/source-screenshots/*.png (iPhone 1320×2868, iPad 2752×2064, Mac) and fastlane/custom-pages/analyse-competitions/source/*.

iphone-01analyze / ipad-01analyze / mac-01analyze: the multi-flight map (6 competition flights, “6 flights · 25 Jun 2026”)

  • Map: a muted light base map with airspace outlines. The selected flight is drawn with the altitude gradient (green→cyan→blue); the other tracks are thin purple lines. Each glider marker carries a competition-ID pill (“EY”, filled for the selected flight).
  • Dashed task lines:
    • task legs: blue-grey
    • optimised track: orange
    • task attempt: green
  • Turnpoint zones: beige circles.
  • Nav bar (iPhone): a back chevron alone at leading; a glass pod at trailing with satellite-cloud download, compare (⧉-like), i and ….
  • Nav bar (iPad): adds the sidebar toggle and the title/subtitle (“6 flights / 25 Jun 2026”).
  • Above the panel: a wind pill bottom-left (“8 km/h / 84°”) and a phase pill bottom-right (”↘ 178 km/h / 85.49 km”).
  • Bottom glass panel:
    • A grabber at the top.
    • A horizontal strip of flight chips: big comp-ID, date, then altitude / vario / speed. The selected chip is filled purple with white text.
    • The barogram:
      • Y labels in “m”.
      • Terrain fill in brown.
      • Green thermal bands.
      • The selected trace in purple, the other flights faint grey.
      • Top badges: orange optimisation S/1/2…/F, green task S/1…/F.
      • An “AAT: 4h” line.
      • Event rings: release, highest, lowest.
      • The orange handle line with an orange bubble “1285 m AGL / 15:10:06”.
    • Bottom row: ”▷ Play” on the left, “Pressure altitude ⌃⌄” unit menu on the right.

ipad-07performance: single flight with the detail open

  • Nav title: pilot name, with the subtitle “OO-YEY – Ventus 2ax · 25 Jun 2026”.
  • Panel (one flight): a single “line” row: “EY 25 Jun · 1240 m AGL · 1725 m · −0.83 m/s”. Below the graph, “Reset zoom” appears because the graph is zoomed.
  • Right-hand popover/inspector, “Declared task” page:
    • A rounded mini map.
    • A 2-column metric card: Distance 448.95 km, Average speed 107 km/h, Start height, Start groundspeed, Finish height, Relative finish height “+26 m” (green), Relative duration on AAT “+00:12:35” (green), Detour.
    • A “Legs” list with green check circles. Each row shows the name (“121Gerolstein”), the caption “14:44:57 · 119 km/h · 2.32 m/s · ↻ 14.41 % · ⇄ 14.97 km”, and a trailing distance in bold.
  • iphone-07performance shows the same page as a full-height sheet with a back chevron.

iphone-06thermal: thermal detail card over the map

  • Header: ”✕ | ⌗ Thermal 14 / 28 | ← →”.
  • Segmented control: Climb / Centering / 3D.
  • “Altitude” column chart: bars coloured yellow→green by climb, with the red marker at the scrub point; Y labels 1410/1628/1845 m; X labels 1:00, 2:00.
  • “Climb rate” band chart.
  • Caption: “Last 0:30 at 3.43 m/s · still climbing when you left”.
  • Phase pill: ”↻ 3.40 m/s / 435 m”.

iphone-10compare / ipad-10compare: Compare flights

  • Header: ”✕ Compare flights”, with a filter button (three lines).
  • Columns: glass capsules with the comp ID and 🥇🥈🥉 (“EY”, “HI”, “CC”, “GP”, “UU”, “XL”). There are 2 columns on iPhone and 6 on iPad.
  • Rows: grouped cards with the left label column. Labels with info have a dotted underline (“Flew alone”, “Height gained / lost”, “Detour”).
  • Values: numbered podium pills ① ② ③ in gold/silver/bronze tint; secondary values below (e.g. “104 km/h”).
  • Sections: “TASK”, “LEG TO 099ULMEN”.

custom-pages/analyse-competitions: “Download competition flights”, “Compare who led and followed”, “See who found thermals first”, “Gold, silver and bronze badges”

  • iphone-01 is the Soaring Spot competition list: large title, “Recently opened” subtitle, inset-grouped rows with name / site + flag / date range, and a filter button.

Not in the screenshots: the flight list and gallery, import screens, edit flight, statistics. Use the FlightScreensDemo / ImportScreensDemo apps to capture them (CLAUDE.md “Feature demos”: -DemoScreen "<Section>/<Title>").


Current design. Docs/notes-style-navigation-investigation.md §8–9 describes a tab-bar/UITabGroup shell built on the separate refactor/ux branch. The code in this tree is the other route (refactor/sidebar): a two-column UISplitViewController (sidebar + one detail column). The list sits in the detail column and a flight is pushed on top of the list. This spec follows the code; doc-only ideas are in §3.15.

Modules/App/Sources/Shell/Split/SplitViewController.swift (1056 loc), SplitViewModel.swift

  • UISplitViewController(style: .doubleColumn).
    • Primary column: SidebarViewController, fixed at 260 pt (no resize).
    • Secondary column: one DetailDestination.
    • There is no third column. The list and the flight are never side by side.
  • Display mode:
    • Default is .oneBesideSecondary.
    • The mode is persisted in Defaults[.splitDisplayMode].
    • presentsWithGesture = true.
    • The Mac uses .tile.
  • Inspector column (trailing), defined in Split/UISplitViewController+Inspector.swift:
    • Width 270 pt (min 250, max 480).
    • Used by the map screens to show the flight-detail panel. It is toggled by the map’s i button (⌘I, flight.button.opendetail).
    • On the Mac it is wrapped in InspectorNavigationController / InspectorPageController: tabs at the top, and a plain ”‹ Title” back row for pushed pages.
  • Launch behaviour:
    • First-launch paywall, if the offering asks for one.
    • Downloaded-file update check:
      • Alert “Updates available” (files.label.updates.available.title).
      • Buttons: Cancel and “Update”.
    • Interrupted-recording recovery:
      • Alert “Continue flight?”
      • Buttons: Continue and Discard (destructive).

Split/DetailDestination.swift, Sidebar/List/Configuration/ListConfiguration.swift

The destination is persisted as "list", "map" or "service.<raw>", so the app relaunches where the user left.

DestinationDetail column shows
.listOne ListViewController shared for the app’s lifetime. Scroll position, search and filter survive trips to the map. Which list it shows is persisted in Defaults[.list] (default .logbook). Large titles are on.
.mapThe “empty map”: the task map in non-editing mode, on the home base. It has no empty state.
.service(x)A Discover browser: WeGlide, SkyLines, Charron, Soaring Spot. Each is built once per session.

ListConfiguration values:

ValueRaw valueTitle
.logbookflights.logbookFlights
.recentsflights.recentsRecents
.plannertasks.plannerTasks
.waypointswaypoints.filesWaypoints
.airspaceairspace.filesAirspaces

There is no “nothing selected” placeholder. The detail column always holds a destination. A flight is not “selected” into a separate pane; it is pushed over the list.

Shell/Sidebar/{SidebarItem,SidebarSection,SidebarViewModel,SidebarViewController,SidebarView}.swift

  • Sections:
    • Library (sidebar.label.library.title) and Discover (sidebar.label.discover.title) are collapsible.
    • Their expanded state is persisted. Both default to expanded.
SectionRow (key)IconCountTap
—Explore map (sidebar.label.shortcuts.map)map–Empty map. On phone, zooms out of the row.
—Recents (sidebar.label.recents.title)clocknoneshowList(.recents)
LibraryFlights (sidebar.label.flights.log)airplane.path.dottedLogbook flights (data != nil)showList(.logbook)
LibraryTasks (sidebar.label.tasks.all)asset “Task”yesTasks list
LibraryAirspaces (sidebar.label.shortcuts.airspace)square.3.layers.3dyesAirspace files list
LibraryWaypoints (sidebar.label.shortcuts.waypoints)mappin.and.ellipseyesWaypoint files list
DiscoverFiles (sidebar.label.shortcuts.files)folder–Document picker (IGC, multi-select). Opens the files on a map without importing them. The row does not take the selection.
DiscoverSoaring Spot / WeGlide / Charron.line / SkyLinesassets–That service’s browser. Always listed, even when no account is connected.
  • Counts:
    • Shown as secondary text.
    • Recomputed with a 100 ms debounce on store or unit changes.
  • Selection:
    • Regular width: the current destination’s row stays highlighted.
    • Compact: no row stays highlighted, and rows show chevrons.
  • Collapsed (phone) appearance:
    • Large title “Vulture”.
    • Subtitle sidebar.label.flights.last “Last flight %@ · · ”. It uses the task distance and speed when part of a task was completed, otherwise the optimized values.
    • With no flights, the subtitle is “No flights yet” (sidebar.label.flights.none).
    • Inset-grouped list.
  • Expanded (iPad sidebar) appearance: sidebar style with no title.
  • Overflow … menu (not on the Mac): Support (lifepreserver) and Settings.
  • Bottom toolbar (not on the Mac): a button “Start flying” (airplane.departure, flight.button.create).
    1. Checks the Pro gate .recording.
    2. Shows the overlay “Starting flight…”.
    3. Asks for location and altimeter permission.
    4. On success, replaces the root with the in-flight map.
    5. On failure, shows the alert “Location usage disabled” (Open Settings / Ok).
  • Drop onto sidebar rows (SidebarViewController+Drop.swift):
    • IGC → Flights, CUP task → Tasks, CUP → Waypoints, OpenAir/CUB → Airspaces.
    • The file is imported, then the matching list is shown.
    • Drops from inside the app, or onto the wrong row, are forbidden.
iPhone / compactiPad / regularMac
StructureOne stack: Sidebar → List → Flight map (push)Sidebar 260 pt beside the detail column; the detail stack is List → Flight map. Sidebar can be hidden or overlaid.As iPad, plus inspector, menu bar, multi-window
Collapse targetThe detail column if it holds a map with data, the list, a service browser, or the filter, or if the user was last in a list (Defaults[.listVisible]). Otherwise the sidebar.––
Picking a destinationPush (map: zoom transition)Swap column without animationSwap
List chromeBottom toolbar: filter · search · add. Nav bar: statistics + overflow.Nav bar: add · statistics · filter · overflow. Search integrated in the nav bar.Nav bar: glass + · statistics · filter. Menus live in the menu bar.
  • The compact rule is horizontalSizeClass == .compact || verticalSizeClass == .compact. A phone in landscape therefore also gets the bottom bar.
  • Layouts follow the window, never the device (iPhone Duo rule in CLAUDE.md). On Android, use WindowSizeClass, never the device type.

ListViewController+Actions.swift

  1. A tap opens the flight (a double-click on the Mac). It builds FlightMapViewController.controller(with: .init(decodableFlights: [flight])).
  2. If the task editor has unsaved work (BlockingController), an alert asks first. Its Discard button is destructive.
  3. The map is pushed onto the list’s stack. If a map is already above the list, the stack is reset to [list, newMap], so flights replace each other rather than stack.
  4. Transition:
    • Gallery card: a zoom transition (.detailZoom) from the card snapshot.
    • List row: a plain push. Zoom was dropped for rows because its swipe-back gesture conflicted with row swipes.
  5. Once open, the flight is marked read (isRead = true) and stamped lastOpenedAt, which adds it to Recents. Recents keeps the last 50 flights.
  6. Coming back to the list clears the highlight and the selection.
  7. Several flights selected → “Open N flights” opens one map with all of them, which is the compare map. More than one flight is Pro-gated (paywall .severalFlights).
  8. Flights arriving from outside go through show(controller, overList: .logbook). Sources are universal links, Spotlight, widgets, the Files row and Open-in.
    • The Logbook is put underneath, so Back lands there.
    • If a flight map is already on top, the new flights are added to it (controller.add(items:)).
  • Open in New Window (menu.button.openinwindow, macwindow.badge.plus):
    • Opens a detail window: a split view with no sidebar, plus the inspector.
    • Several flights open as one window, which is Pro-gated.
  • Statistics on the Mac is a single-instance window.
  • Handoff advertises the current list, service or map.
  • Files: Application/NSUserActivity+Windows.swift, DetailSceneDelegate.swift, Split/DetailWindowSplitViewController.swift, SplitViewController+Handoff.swift.
iOSAndroid (Compose, M3 Expressive)
Sidebar: 2 top rows + Library ×4 + Discover ×5 + Start flyingCompact: a root “Library” screen mirroring the collapsed sidebar: LargeFlexibleTopAppBar titled “Vulture” with the last-flight subtitle, grouped list with counts, and an ExtendedFloatingActionButton “Start flying”. Eleven items don’t fit a bottom bar, so don’t force NavigationSuiteScaffold into a bottom-bar shape here. Medium: modal NavigationDrawer or WideNavigationRail (expanded, collapsible headers). Expanded: PermanentNavigationDrawer/WideNavigationRail at 260 dp. NavigationSuiteScaffold with a custom NavigationSuiteType per size class covers rail/drawer switching.
Detail column: list → pushed flight mapTo match iOS exactly: one pane per destination with a nested NavHost (list → map). Opening a second flight replaces the first; Back goes to the list. ListDetailPaneScaffold (list beside map) would be a deliberate deviation. Recommended only as an opt-in on Expanded width if parity is relaxed.
Inspector column on the mapSupportingPaneScaffold: main = map, supporting = detail panel, 250–480 dp. Compact uses a bottom sheet (see §5).
Statistics modal / Mac windowFull-screen route (Dialog with usePlatformDefaultWidth=false). On large screens optionally a new task via FLAG_ACTIVITY_LAUNCH_ADJACENT.
Open in New WindowFLAG_ACTIVITY_NEW_DOCUMENT or FLAG_ACTIVITY_MULTIPLE_TASK, offered only on large screens / desktop windowing.
Persisted destination, list, expanded sectionsDataStore keys detailDestination, list, libraryExpanded, discoverExpanded.

Map these to Android onKeyEvent / KeyboardShortcutGroup for ChromeOS, tablets and desktop windowing.

KeysCommand
⌥⌘1–9Explore map, Flights, Tasks, Airspaces, Waypoints, Soaring Spot, WeGlide, Charron.line, SkyLines
⌘1 / ⌘2View as Gallery / View as List
⌘+ ⌘− ⌘0Gallery zoom in / zoom out / actual size
⌘FFind
Return or ⌘↓Open selection (“Open flight” / “Open %u flights”)
⌘ASelect all (enters select mode)
⌘⌫Delete selection (asks first)
⌘OAdd Flights… (menu.button.importflights)
⌥⌘NNew window
⌘,Settings

Home-screen quick actions are “Create task”, “Show the map” and “Start/Stop flying”. On Android these become static/dynamic ShortcutManager shortcuts.


Shell/Sidebar/List/ListViewModel.swift, Configuration/ListConfiguration+Data.swift, ListViewModel+Filter.swift, rows in Packages/FlightScreens/.../FlightList/.

  • Core Data CoreFlight through an NSFetchedResultsController (batch 20). Rows are rebuilt as value types (SidebarListSection/SidebarListEntry/FlightListRowItem) with a row cache.
  • Predicates:
    • Logbook: data != nil. This excludes service flights kept only for Recents.
    • Recents: lastOpenedAt != nil AND (data != nil OR recentData != nil).
    • Final predicate: base AND search AND filter.
  • Header subtitle: the count of rows shown, flight.label.subtitle “No flights” / “%u flight” / “%u flights” (plural).
  • Android: Room Flow<List<FlightRow>> via PagingData or plain lists. Unit changes re-map rows without re-querying.

Filter/ListSortType.swift, ListViewController+SortMenu.swift

  • Logbook sorted by Date: one section per take-off year.
    • The header is a row in title2.bold, not pinned and not selectable.
    • The gallery uses the same header.
  • Any other sort, and Recents with any sort: a single section with no headers. There are no month sections.

Sort options live in overflow → “Sort By” (list.sort.title, arrow.up.arrow.down):

ListOrders (menu order)Default
LogbookDate (calendar, key takeoffAt) · Duration (clock, duration) · Distance (ruler, optimizedDistance) · Speed (speedometer, optimizedAverageSpeed)Date
RecentsLast Opened (clock.arrow.circlepath, lastOpenedAt) · Date · Duration · Distance · SpeedLast Opened
  • Direction is a second inline group under the orders:
    • “Newest First” / “Oldest First” for date and last-opened.
    • “Largest First” / “Smallest First” for the others.
  • The default direction is descending.
  • Each order remembers its own direction, separately for Logbook and Recents.
  • The menu stays open while the user picks, so order and direction can both be set in one visit.
  • Android: a DropdownMenu with two DropdownMenuGroups (M3 Expressive menu groups) and checkmarked items. Keep it open on click.

Files:

  • Packages/FlightScreens/.../ListFilter/{ListFilterItem,ListFilterView,ListFilterViewModel,ListFilterProviding}.swift
  • Hosting/ListFilter/ListFilterHostingController.swift
  • Modules/App/Sources/Features/Flight/ListFilter/AppListFilterProvider.swift

Entry point and presentation:

  • The button is line.horizontal.3.decrease with the accessibility label “Filter flights”.
    • Compact: in the bottom toolbar.
    • Regular: in the nav bar.
  • iPad shows a popover. iPhone shows a sheet with medium and large detents and a grabber.
  • There is no Done or Clear button. Every tap applies and persists immediately.

Content is a 3-column grid of glass tiles (height 56, radius 12, spacing 8). Each tile is an icon over a footnote label. A selected tile gets a 30 % accent tint and a semibold label.

  1. Countries (list.filter.countries.title). Only shown if any flights have a country.
    • One tile per distinct takeoffCountryCode: flag emoji + localized name, sorted by name.
  2. Criteria (list.filter.filter.title):
#Label (key list.filter.filter.type.*)IconColourPredicate
1Favoriteheart.fillFavoriteisFavorite
2Recordedrecord.circlebrandisRecordedByVulture
3Contestcrown.fillContestisContest
4Field landingcar.fillCarisFieldLanding
5Task Finishedasset TaskSuccessisTaskCompleted
6Balasteddrop.fillWaterwaterBalast > 0
7UnreadeyeglassesInfringementisRead == NO
8No commentbubble.left.and.bubble.righttertiarycomments empty
  • Logic:
    • Countries are OR’d together.
    • Criteria are AND’d together.
    • The two groups are AND’d.
  • Persistence: Preferences.listFlightFilter, keys country.<cc>, flights.favorite, flights.recorded, flights.unread, flights.contest, flights.fieldlanding, flights.completedtask, flights.nocomment, flights.water.
  • Active cue: the filter glyph is drawn at heavy (black) weight while a filter is on. There is no other cue.
  • Housekeeping:
    • Stored countries that no longer exist are pruned.
    • If the last matching row disappears (and no search is active), the filter is cleared automatically.
    • A filter that matches nothing when applied stays on and shows “No results”.
  • Android:
    • ModalBottomSheet on compact, DropdownMenu/popup or Popup card on expanded.
    • Tiles map to FilterChips in a FlowRow, or ToggleButtons in a 3-column grid.
    • A filled IconButton signals the active state; M3 Expressive IconToggleButton “selected” shape morph also works.

ListConfiguration+Data.swift (searchPredicate)

  • Compact: an expanding search button in the bottom toolbar between filter and add.
  • Regular: integrated in the nav bar.
  • The search runs as the user types.
  • Placeholder (flight.label.search, plural): “Search flights” / “Search 1 flight” / “Search %u flights”.
  • Matched fields (contains, case- and diacritic-insensitive, OR’d): pilotName, passengerName, gliderRegistration, gliderType, competitionIdentification, comments, takeoffCode, takeoffName.
  • Applies to Logbook and Recents, AND’d with the filter.
  • Android:
    • M3 SearchBar + ExpandedFullScreenSearchBar / SearchBarDefaults.InputField in the top bar. On compact, use the docked AppBarWithSearch (M3 Expressive 1.4).
    • Diacritic-insensitive matching: normalize to NFD and strip marks in a Room function or in memory.

ListViewController+NavigationBar.swift, SidebarListModels.swift, SidebarGalleryView.swift

  • The default is GALLERY. The choice is persisted (listFlightGallery) and shared by Logbook and Recents.
  • The overflow menu toggles between “View as Gallery” (square.grid.2x2) and “View as List” (list.bullet).
  • Gallery zoom (regular width only, not phone landscape):
    • “Zoom Out” / “Zoom In” tiles in the overflow menu. Mac also has ⌘0 “Actual Size”.
    • Persisted. The default is medium. Animated .snappy(0.28).
  • Column count:
Gallery widthColumns
< 4602
460 to < 5003
≥ 500smallest 6, small 5, medium 4, largest 3

The count is capped at 4 when the height is under 500.

  • Grid metrics: column spacing 16, row spacing 20, padding 20.
  • Android: LazyVerticalGrid(GridCells.Fixed(n)) computed from BoxWithConstraints; Modifier.animateItem() on zoom changes.

3.6 Row anatomy (FlightListRow.swift, CoreFlight+FlightListRowItem.swift)

Section titled “3.6 Row anatomy (FlightListRow.swift, CoreFlight+FlightListRowItem.swift)”

Overall: a horizontal stack, 12 pt gap, padding 16 × 7.

  1. Leading thumbnail (TrackThumbnailTile)
    • 44 pt, scaling with Dynamic Type. Corner radius = size / 5.5.
    • Content, in priority order:
      1. A muted Mapbox snapshot with the track drawn on it (light or dark variant), fading in over 0.25 s.
      2. Otherwise the track polyline in the accent colour with a start marker.
      3. Otherwise the airplane placeholder.
    • Corner dot at top-trailing, 10 pt with a 2 pt ring:
      • Accent = recorded by Vulture.
      • “Unread” gold = not yet opened.
      • Recorded wins over unread.
  2. Line 1
    • Pilots in headline: “Pilot, Passenger”, capitalised, or ”—” when both are empty.
    • Spacer.
    • Date in subheadline, secondary:
      • Logbook: EEE d MMM, e.g. “Sat 23 Aug”. The year comes from the section header.
      • Recents: medium date with year.
    • Chevron.
  3. Line 2
    • Glider: gliderType, otherwise the registration in uppercase.
    • Badges (caption2): heart (Favorite), crown (Contest), car (Field landing), drop + litres (Water).
    • Figure chips: caption, monospaced digits, capsules on tertiarySystemFill.
      • Order: distance · duration · speed.
      • Distance is the task distance if > 0, otherwise the optimized distance, in whole units.
      • Duration is compact: 4h12m, 3h or 05m.
      • Speed is the task speed when judged on the task, otherwise the optimized speed.
      • Zero or missing values are omitted.
      • At accessibility text sizes the chips move to their own line.
      • Chips are hidden in select mode.
  4. Line 3
    • Comment in footnote, secondary, 1 line.

States:

  • Active row:
    • Regular width: an accent card (radius 16) with white text.
    • Compact: a systemFill background.
  • Picked row (select mode): a systemFill grey background. Consecutive picked rows merge into one block.
  • Select mode: a leading circle / checkmark.circle.fill.

Accessibility: the label is the pilots; the value is date, glider and figures.

Android: ListItem (M3) or a custom Row. Use SuggestionChip-style small surfaces or plain Surface(shape = CircleShape) for the figure chips. Badge for the corner dot. Generate thumbnails with the Mapbox Android Snapshotter, cached on disk.

Section titled “3.7 Gallery card (GalleryCard.swift, DesignSystem/Components/GalleryTile.swift)”
  • Tile
    • Aspect ratio 1 : 0.92, radius 18.
    • Map artwork with a top sheen, otherwise the accent track (line 2.5), otherwise an airplane.
    • 1 pt gradient border, shadow (black 18 %, radius 8, y 4).
    • Selected: 2 pt accent ring.
  • Overlays
    • Top-leading: the select tick or the unread/recorded dot.
    • Top-trailing: badges.
  • Caption (centred, 8 pt below the tile)
    • Line 1: pilots in subheadline.
    • Line 2: “date · glider” in footnote, or just the date if both don’t fit.
InputTouchMac
TapOpen the flightClick selects (Shift extends, ⌘ toggles); double-click within 0.5 s opens
Long pressContext menu with previewRight-click menu
SwipeNo swipe actions on flight rows. Only waypoint and airspace file rows have a leading Show/Hide on Map swipe.–

Context-menu preview (SidebarListPreview.swift):

  • Width 280–340.
  • 170 pt map header.
  • Pilots + date.
  • Glider.
  • Metric grid:
    • Distance (flight.statistics.data.optimized.distance)
    • Duration HH:mm:ss
    • Average speed (flight.info.label.cruise.speed)
    • Height gain (flight.info.label.altitudegained)
  • Comment, up to 6 lines.

Android has no peek-preview. Use combinedClickable(onLongClick) → a ModalBottomSheet that shows the preview card above the menu actions. Alternatively use a DropdownMenu anchored to the row, plus a haptic.

Built lazily: ListViewController+Menu.swift, Shell/Menus/FlightMenuBuilder.swift.

#GroupItemsConditions
1WindowOpen in New WindowiPad and Mac only
2ShareShare (flight.button.share): IGC share sheetAlways
2ShareUpload flight ▸ (flight.button.upload): SkyLines / WeGlide / CharronConnected services only; SkyLines is Pro (.uploads)
2Share(DEBUG) Simulate flightDebug builds
3RecentsSave to flight log (flight.button.save, folder.badge.plus)Recents list only, recent-only flights
3RecentsRemove from Recents (recents.button.remove)Recents list only
4MarkMark flight ▸ (flight.button.mark): Favorite / Contest / Field landing, each a toggleNot for recent-only flights
5EditEdit flight (flight.button.edit): form sheet “Edit flight”Not for recent-only flights
5EditDelete flight (flight.button.delete, destructive)Logbook only. Touch: a submenu [Cancel, Delete flight], which deletes immediately. Mac: an alert.
WindowTop barBottom bar
CompactStatistics (chart.bar, Logbook only) + overflow …Filter ‹flex› Search ‹flex› + Add. Recents has no filter and no add.
Regular+ Add · [Statistics, Filter, …]none
  • Title and subtitle are hidden below 360 pt width.
  • + Add menu (Logbook):
    • Group 1: Files (list.button.files).
    • Group 2: Bluetooth device (flight.button.download), Wi-Fi (flight.button.download.wifi).
    • See §4.
  • Overflow menu (in order):
    1. Zoom Out | Zoom In (gallery, regular width only)
    2. Sort By ▸
    3. View as Gallery / List
    4. Select (shows Done while selecting)
    5. Clear Recents (Recents only; destructive, no confirmation)

Android:

  • Compact uses the M3 Expressive HorizontalFloatingToolbar (or a docked FlexibleBottomAppBar) holding filter, search and a FAB-style ”+” that opens a menu. The FAB can be a FloatingActionButtonMenu with “Files”, “Bluetooth device” and “Wi-Fi” items, which is the M3 Expressive equivalent of the iOS + menu.
  • Expanded uses TopAppBar actions.
  • Use IconButton + DropdownMenu for the overflow, since the system ellipsis does not exist on Android.
  • Enter: overflow → Select, or ⌘A.
  • While selecting:
    • Rows show ticks and hide their figures. Context menus are off. A tap toggles a row.
    • Top bar: [Select all] · [Delete (Logbook only), Share] · [✓ Done].
    • Delete and Share stay disabled until something is picked.
    • Bottom: a prominent button “Open flight” / “Open %u flights” (flight.button.open; zero → “Select a flight”). It is shown once at least one row is picked.
  • Bulk actions:
    • Open: one multi-flight compare map. Pro is required for more than one flight.
    • Share: all the IGC files.
    • Delete: a confirmation:
      • Title: “Delete flight?” / “Delete %u flights?” (flight.button.delete.title)
      • Message: “Are you sure you want to delete this flight? This action can not be undone.” (flight.button.delete.message.one/.other)
      • Buttons: Delete (destructive) / Cancel.
  • Not available: no merge, zip export, bulk mark or bulk rename. “Compare” means opening several flights on one map.
  • Android:
    • The top app bar switches to a contextual selection bar (TopAppBar with a count title).
    • Bottom: a large Button (M3 Expressive large/XL button size) “Open N flights”.
    • Delete confirmation: AlertDialog.

ListViewController+Drag.swift, +Drop.swift, Shell/DragAndDrop/*

  • Drag out:
    • Payload: IGC text. File name is the stored filename, otherwise {compId|registration|pilot}_{ddMMyyyy}.igc, otherwise Flight.igc.
    • Copy only.
    • In select mode, all picked rows are dragged.
    • Private marker type be.fousa.vulture.library-item blocks dropping back into the library.
    • Dropping onto a flight map adds the flight to it.
  • Drop in: IGC from other apps onto the list, the gallery or the sidebar Flights row imports the files. Errors are reported together at the end.
  • Android:
    • Drag out: Modifier.dragAndDropSource with a ClipData content URI (FileProvider).
    • Drop in: Modifier.dragAndDropTarget for multi-window / desktop.

ListConfiguration+Properties.swift, SidebarListPlaceholderView.swift (ContentUnavailableView), SidebarListNoticeView.swift

SituationTitleMessageAction
Logbook empty“No flights yet” (flight.label.empty.title)“Import a flight from your logger or from Files.” (flight.label.empty.message)“Add” menu button, same items as + (.placeholderAction() glass capsule)
Recents empty“No recent flights” (recents.label.empty.title)“The last %d flights you open are listed here.” (%d = 50)–
Search or filter matches nothing“No results” (list.label.noresults)“There are no flights available for the selected criteria.” (flight.label.empty)–
  • Flight lists have no notice banners. The only banner is the openAIP notice, and it lives on the Airspace list.
  • Android: a centred Column with an icon, titleLarge, bodyMedium, and a FilledTonalButton (the equivalent of the glass capsule .placeholderAction()).

Packages/FlightScreens/.../Statistics/*, Hosting/Statistics/FlightsStatisticsViewController.swift (a UIKit collection view reusing CompareFlightsCollectionViewLayout)

  • Entry and presentation:
    • The chart.bar button on the Logbook.
    • Pro-gated (.statistics).
    • Full-screen modal titled “Statistics” (flight.statistics.title), with Close at leading.
  • Grid:
    • Columns are take-off years, newest first. Year headers are glass pills.
    • Rows are metrics in 5 groups.
    • Tapping a value highlights its whole metric row.
  • Podium:
    • Only when there is more than one year.
    • Each row’s top 3 distinct non-zero values get gold, silver and bronze N.circle.fill badges.
    • Higher is better, except field landings, where lower is better.
Group (flight.statistics.section.*)Metric (flight.statistics.data.*)Aggregation
FlightsTotal flightscount
FlightsDurationsum, whole hours
Optimized distanceDistancesum of optimized distance
Optimized distanceLongest distancemax
Optimized distanceAverage speedmean
Optimized distanceBest average speedmax
TaskTotal completedcount of isTaskCompleted
TaskTask / free flightcompleted ÷ count, “%0.0f %”
TaskTotal distancesum of task distance
TaskLongest distancemax
TaskAverage speedmean
TaskBest average speedmax
PerformanceTotal competitionscount of isContest
PerformanceTotal fieldlandingscount (lower is better)
PerformanceAverage cruise speedmean
PerformanceBest cruise speedmax
PerformanceAverage water balastmean over ballasted flights only
ThermalsTotal altitude gainedsum
ThermalsBest variomax
ThermalsTotal right turnssum
ThermalsTotal left turnssum
  • Averages include flights with zero values, except water ballast.
  • Android: a 2-D scroll grid with a frozen metric-label column (LazyColumn of rows with a shared horizontalScroll state). Use the same composable as Compare (§5.5.3).

3.15 Not built on iOS (candidates, flag as deviations)

Section titled “3.15 Not built on iOS (candidates, flag as deviations)”

These come from Docs/notes-style-navigation-investigation.md §9 and Docs/hig-compliance.md.

  • A visible filter state: a “37 of 412 flights” subtitle and removable filter chips under the title.
  • Clear and Done buttons in the filter sheet.
  • Statistics as a destination.
  • Root search across flights and tasks.
  • An “Explore” group with the service picked from the title.

4.0 Two outcomes, which is the key concept

Section titled “4.0 Two outcomes, which is the key concept”
OutcomeBehaviourEntry points
A. Import into the logbook (“flight log”)1. The IGC is decoded.
2. It is saved as CoreFlight with isRead = false (unread).
3. No navigation and no success message. The list fills in.
4. Errors are reported once at the end.
Add menu › Files; Mac File › “Add Flights…” (⌘O); drag-drop onto the Logbook/Recents list or the sidebar Flights row; Bluetooth (LXNAV NANO); FLARM over Wi-Fi; the app’s own recording (isRead = true, isRecordedByVulture)
B. Open on the map without savingThe flight is decoded or downloaded and shown on the flight map. The selected flight is kept as a “recent-only” record in Recents (recentData). The user can later tap “Save to flight log” (flight.button.save).Share sheet / “Open in Vulture” for .igc; sidebar Discover › Files; WeGlide; SkyLines; Soaring Spot; Charron.line; universal links and vulture:// (Safari extension); drop onto an open map; Handoff
  • IGC is the only flight format. There is no GPX, KML, KMZ, ZIP or folder import. ZIP exists only as a backup export (Services/System/Backup.swift).
  • CUP, OpenAir .txt, openAIP .aip and .cub files carry waypoints, tasks and airspace, never flights.

4.1 Declared types (Modules/App/Resources/Info.plist) and the Android equivalent

Section titled “4.1 Declared types (Modules/App/Resources/Info.plist) and the Android equivalent”
iOSValueAndroid
UTImportedTypeDeclarations / CFBundleDocumentTypespublic.igc (igc), public.cup, public.cub, public.aip; OpenAir as plain text<intent-filter> for ACTION_VIEW + ACTION_SEND/SEND_MULTIPLE with content:// and file schemes. Use pathPattern=".*\\.igc" plus mimeType="application/octet-stream", "text/plain" and "*/*", because IGC has no registered MIME type. Sniff the content: the first line starts with A.
LSSupportsOpeningDocumentsInPlace, UIFileSharingEnabledtrue; the app never scans Documents (DEBUG -VultureProbeImportDocuments aside)n/a
URL schemevulture<data android:scheme="vulture"/>
Associated domainapplinks:vulture.aeroApp Links: autoVerify + assetlinks.json on vulture.aero (backend work)
Bluetooth usage“Connect to LXNAV devices.”BLUETOOTH_SCAN (neverForLocation) + BLUETOOTH_CONNECT runtime permissions

1. Logbook ”+” › Files (outcome A)

  • Files:
    • ListViewController+Toolbar.swift (flightImportElements)
    • Shell/Split/SplitViewController+Import.swift
    • Pipeline: Shell/Split/SplitViewModel.swift import(fileURLs:)
  • Picker:
    • Document picker restricted to public.igc.
    • Multi-select, file extensions shown.
  • Pipeline (background queue):
    • Each file is routed by its lower-cased extension: igc → flight, cup → waypoints, txt/aip/cub → airspace. Anything else is ignored.
    • Flight steps:
      1. FlightDecoder(fileURL:) with sport profile Preferences.parsingSportProfile.
      2. decode(), which parses and enriches.
      3. createFlight(isRead: false).
    • Errors are collected as VultureError.importFailed and presented after the last file.
  • No progress UI.
  • Android:
    • rememberLauncherForActivityResult(OpenMultipleDocuments()) with arrayOf("*/*"), then filter on .igc, because MIME types are unreliable.
    • Run the import in a WorkManager job or a coroutine on Dispatchers.IO.
    • Optional deviation: show a Snackbar (“3 flights imported”). Decided: no, silent as on iOS (decisions §6).

2. Sidebar Discover › Files (outcome B)

  • Same picker, purpose .openOnMap.
  • Each file becomes a FlightDocument.
  • If a flight map is visible, the flights are added to it. Otherwise a map opens over the Logbook.

3. Share sheet / Open in / Files tap (Application/SceneDelegate.swift, SceneDelegate+Routing.swift)

  • Waits for ProAccess.isAppOpen: immediate on the free tier, otherwise after the hard paywall.
  • Cold-launch URLs are queued and replayed.
  • Routing uses the first URL’s extension for the whole batch:
    • igc → dismiss any modal, then add to the visible map or open a map over the Logbook (outcome B).
    • cup → import waypoints and tasks, then the alert:
      • Title: “Import successful”
      • Message: “The waypoints file has been imported successfully.”
      • Buttons: “Open files” / “Ok”
    • airspace → the same alert (“The airspace file has been imported successfully.”).
  • Android: handle in MainActivity.onNewIntent / intent (ACTION_VIEW, ACTION_SEND, ACTION_SEND_MULTIPLE) and forward to a shell ViewModel event.

4. Drag and drop (iPad/Mac; Android: multi-window, ChromeOS, desktop windowing)

  • Onto the Logbook/Recents list or gallery:
    • Accepts IGC as a copy.
    • Imports through pipeline A.
    • Drags from inside the app are refused (marker type be.fousa.vulture.library-item).
    • Files: ListViewController+Drop.swift, Shell/DragAndDrop/DroppedFileLoader.swift.
    • DroppedFileLoader copies into tmp/Dropped/<UUID>/:
      • Name: the suggested name plus extension, or Dropped-<n>.<ext>.
      • Clashing names get an <index>- prefix.
  • Onto a sidebar row:
    • Flights ← IGC, Tasks ← CUP, Waypoints ← CUP, Airspaces ← airspace files.
    • The row highlights. After the import, that list is shown.
  • Onto an open flight map (outcome B): an overlay with two targets.
    • Upper target: “Release to add the flight(s)” / “The flights already shown stay”.
    • Lower target, only when the map already has flights: “Release to replace the flight(s)” / “Only the dropped flights are shown”.
    • A CUP-only drop shows “Release to use this task” / “Every flight on the map gets this task”.
    • Pro check (.severalFlights) runs before any file is read.
    • CUP drop is Pro .taskOverride. One task applies directly; several open the task picker. A CUP with no task gives “This file does not contain a task”.
  • Android:
    • Modifier.dragAndDropTarget with DragAndDropTarget.onDrop reading ClipData URIs.
    • Call requestDragAndDropPermissions for cross-app URIs.

5. WeGlide / SkyLines browsers (outcome B): the ImportFlights screen, §4.4.

6. Bluetooth logger: LXNAV NANO (outcome A)

  • Entry: + › “Bluetooth device”.
  • Device picker (ImportScreens/BluetoothDevices/*, Hosting/Bluetooth/Devices/BluetoothScanner.swift):
    • Title “Select a device” (bluetooth.label.devices), with Close.
    • A spinner while scanning. The scan is unfiltered.
    • Recognised devices are those whose name starts with "LXNAV-NANO". They show the LXNAV logo tile, the name and a chevron.
    • Others are listed under “Unsupported”, dimmed, with bolt.slash.fill.
    • When the radio is off, an overlay shows antenna.radiowaves.left.and.right.slash.
  • Protocol (Packages/Bluetooth):
    • LXNAV $PLXVC NMEA, written without response to the first such characteristic, with notify enabled.
    • 30 s timeout per sentence.
  • Flow (Hosting/Bluetooth/Send/BluetoothSendDataViewModel.swift):
    1. Status “Reading flights…” (bluetooth.label.fetch.flights).
      • Sends LOGBOOKSIZE,R, then LOGBOOK,R,1,<n+1>.
      • Builds a list of BluetoothFlight (filename, date/time, size), newest first.
    2. Sheet “Select a flight” (bluetooth.label.flights), header “Downloading over BLE can take a while”.
      • Uses DeviceFlightsView.
      • There is no empty state (a TODO in the code).
    3. On tap:
      • Requests notification permission.
      • Status “Downloading flight…” while reading FLIGHT,R,<file>,<from>,<from+14> in 15-line chunks.
      • A determinate bar (200 pt wide) with ”%@ remaining” (bluetooth.label.duration).
      • The raw NMEA streams in the footer, 4 lines max.
    4. When done:
      • Decode and createFlight(isRead:false).
      • Local notification: “Download finished” / “Vulture finished downloading the flight %@ from device %@ successfully, you can find the downloaded flight in your flight log.”
      • Alert: “Complete” / “The flight is successfully downloaded and saved to your flight log.” with Ok, which unwinds 3 levels.
    5. On error:
      • Alert: “Something went wrong” (common.label.error), with “Try again” and “Cancel”.
      • A failure notification: “Download failed” / “There was a problem downloading the flight %@ from device %@.\n\n%@”.
      • Messages:
        • Connection failed: “There was a problem connecting to the device. Please try again.”
        • Timeout: “The device didn’t respond in time. Please try again.\n\nWhen this doesn’t work try turning bluetooth of the device off and back on.”
        • Other: “Unable to complete the transfer. Please try again.”
      • Decode failures are only logged.
  • Android:
    • BluetoothLeScanner + BluetoothGatt (or Nordic Android-BLE-Library).
    • Run the transfer in a foreground service of type connectedDevice with a progress notification. This replaces both the iOS local notification and the keep-alive.
    • Use LinearWavyProgressIndicator (M3 Expressive) for the determinate bar.

7. FLARM over Wi-Fi: PowerFLARM Fusion (outcome A)

  • Entry: + › “Wi-Fi”.
  • Screen:
    • Title “Send to Wi-Fi device” (wifi.label.title), with Close on the left.
    • Status “Waiting for a connection…”.
    • Footer help: “Connect to the wireless network of the device you want to upload the flight declaration to. Vulture currently supports connecting to PowerFLARM Fusion ©.”
  • Protocol (Packages/Flarm):
    1. HTTP to http://10.10.10.10/api.
    2. Poll GET info every 1 s until a deviceId arrives. The footer then shows the FLARM logo + “Connected to:” + the id.
    3. Status “Reading flights…”: GET flarm/igc returns [{index, filename, timestamp}].
    4. Sheet “Select a flight” (wifi.label.flights).
    5. Status “Downloading flight…”: GET flarm/igc/<index>.
    6. Decode and save, then the “Complete” alert, which unwinds.
  • The spinner is always indeterminate.
  • Android:
    • Needs network_security_config allowing cleartext to 10.10.10.10.
    • Bind the process to the Wi-Fi network (ConnectivityManager.bindProcessToNetwork) because the FLARM Wi-Fi has no internet. Otherwise Android routes the traffic over cellular.
    • Consider WifiNetworkSpecifier.

8. Soaring Spot and Charron.line (Packages/CompetitionScreens, outside this area but these are flight sources; outcome B)

  • Day result lists support multi-select.
  • Bottom-bar actions:
    • “Open flight” / “Open %u flights”.
    • “Replay Day” (competition.button.replayday, play.fill) when nothing is selected.
  • When opening:
    • Items carry their rank. The map sorts and recolours by rank and applies the day’s task.
    • Replay Day auto-opens the Ranking.
  • Downloads:
    • At most 3 concurrent downloads.
    • Soaring Spot’s rate-limit page (200 with “Too many requests”) is retried with 2, 4 and 8 s back-off.

9. Deep links (SceneDelegate+Routing.swift): both https://vulture.aero/… and vulture://… are routed by path.

PathAction
/open/charronline/igc/<year>/<file>Open the downloaded IGC on the map (outcome B)
/open/soaringspot/flight/<id>Same, from https://www.soaringspot.com/en_gb/download-contest-flight/<id>?dl=1
/open/soaringspot/results/<comp>/<class>/<day>Soaring Spot browser at that day
/open/soaringspot/<comp>Soaring Spot competition
/open/flight?id=<objectID URI>Open a logbook flight (widget)
  • Adding to a map that already shows flights asks first:
    • Title: “Replace?”
    • Message: “Would you like to add the flight(s) to the current map or replace them?”
    • Buttons: Add / Replace / Cancel.
  • The Safari Web Extension (Modules/Safari) injects “Open in Vulture” links on Soaring Spot and Charron pages.
  • Android has no Safari-extension equivalent. Two options:
    • The App Links above cover links tapped in Chrome.
    • A Chrome extension already exists in /Chrome and could link to https://vulture.aero/open/....

10. Not imports, but routes to flights

  • Spotlight and the App Intent “Open flight”.
  • App Intents: Show my last flight, Analyse flight, Analyse my last flight, Compare flights. There is no import intent.
    • Android: App Actions / shortcuts.xml capabilities.
  • Handoff.
  • In-app recording (Pro .recording): Packages/FlightRecording turns the fixes into IGC on stop.
  • The QuickLook and Thumbnail extensions render IGC previews in Files. Android has no equivalent; skip.
  • The RemoteFiles screen (ImportScreens) is for waypoint and airspace catalogues only, not flights.

Decoder: Packages/Flights/.../Decorder/FlightDecoder.swift, Models/Flight.swift.

Records parsed:

  • A
  • H: DTE, PLT, CM2, GTY, GID, FTY, GPS, PRS, MOP, CID, CCL
  • E (events)
  • C (declared task)
  • I (extensions)
  • B (fixes)
  • L (observation zones: LLXVOZ, LNAVOZN, LSEEYOU OZ)

Errors:

  • missingDate: “The file doesn’t contain valid IGC formatted content.”
  • invalidFixes (fewer than 2): “This file doesn’t contain coordinate fixes that we can display on the map.”
  • invalidData: “The content of this file could not be read because it was invalid.”
  • remoteURL

Other behaviour:

  • Warnings are kept, not thrown.
  • When there is no filename, it becomes <CID>_<date>.igc.
  • Flight.id is an FNV-1a hash of (filename, date, first fix time, fix count). The map uses it to detect duplicates.

Embedded task:

  • Becomes flight.task, and the best attempt is computed.
  • It is not added to the Tasks planner.

Stored fields:

  • importedAt, updatedAt, filename, isRead
  • takeoffAt (date + take-off fix, with day rollover)
  • pilotName, passengerName, gliderType, gliderRegistration, competitionIdentification, competitionClass
  • isTaskCompleted, taskDeclaredDistance, taskCompletedDistance, taskAverageSpeed
  • optimizedDistance, optimizedAverageSpeed, fastestAverageSpeed (best 2 h)
  • duration, altitudeGained, averageCruisingSpeed, bestThermalVario, left/rightThermalCount
  • takeoffCode, takeoffName, takeoffCountryCode (nearest airport)
  • data.rawContent (the raw IGC)
  • Elevation cache

4.4 ImportFlights screen: WeGlide / SkyLines browser

Section titled “4.4 ImportFlights screen: WeGlide / SkyLines browser”

Files: Packages/ImportScreens/.../ImportFlights/*, Hosting/ImportFlights/ImportFlightsViewController.swift (386 loc).

Services:

WeGlideSkyLines
APIhttps://api.weglide.org/v1/ (cert pinning)https://skylines.aero/api
AuthX-API-Key from the KeychainBasic auth + user id
Scopes offeredYour flights onlySigned in: Your / Latest / Daily. Otherwise: Latest / Daily.
Downloadflightdetail/<id> → igc_file.file → https://weglidefiles.b-cdn.net/<file>https://skylines.aero/files/<filename>
  • Page size is 100. The next page is requested only when the last page was full.

Top bar:

  • Title: the service name.
  • Subtitle:
    • “Not connected” (settings.service.notconnected) when there is no account.
    • “Flights on %@” in the daily scope.
    • Otherwise the scope name: “Your flights” (person.fill), “Daily flights” (calendar), “Latest uploads” (clock), “Friends” (person.2.fill).
  • Trailing items:
    • “Select” (common.button.select).
    • The scope filter line.horizontal.3.decrease, only with more than one scope. Its menu has checkmarks. Picking a scope clears the selection and refetches.
    • While selecting: ✓ Done, plus “Select all” on regular width.

States, in priority order:

  1. Connect prompt (WeGlide with no key):

    • “Connect WeGlide” (link).
    • “Add your WeGlide API key in the settings, under Services, to see your flights here and open them on the map.”
    • Button “Open Settings” (flights.import.connect.button).
  2. Full-screen error (ErrorStateView): headline, sentence and SF symbol per FailureReason, plus “Try again”.

    HeadlineSymbolSentence
    You’re Offlinewifi.slashThis needs an internet connection, and there is none at the moment.
    No Answericloud.slashThe service could not be reached, or took too long to answer.
    Service Unavailableexclamationmark.icloudThe service ran into a problem of its own. That usually clears up within a few minutes.
    Access RefusedlockThe service did not let the app in. If you use an account with it, check in Settings that it is still connected.
    Not Foundquestionmark.circleThe service no longer has what this screen shows.
    Unexpected Answerexclamationmark.triangleThe service answered with something the app could not read. If this keeps happening, the app may need an update.
    Something Went Wrongexclamationmark.triangleThis could not be loaded.

    Keys: error.state.*. A failure clears the rows and ends the select session.

  3. Loading: a full-screen spinner (LoadingOverlay), with a VoiceOver announcement when done.

  4. Empty: “There are no flights available from WeGlide.” / “…from SkyLines.”

  5. No search results: ContentUnavailableView.search.

  6. The list.

List behaviour:

  • Pull-to-refresh.
  • Infinite paging when the last row appears.
  • Row layout (ImportFlightRow):
    • [SelectionTick in select mode]
    • Pilot name, with a caption "23 Aug · Ventus 3 · Sint-Truiden 🇧🇪".
    • Trailing: distance (semibold, monospaced) over speed (caption).
    • A picked row has a systemFill background.

Selection:

  • Touch: a tap outside a session opens that one flight. “Select” starts a session where a tap toggles. ✓ ends it and clears. Select all picks the visible rows.
  • Mac: click, ⌘-click and ⇧-click to select; double-click or Return opens.

Bottom bar:

  • [Select all (compact, while selecting)]
  • [Calendar “Choose date” (daily scope only), which opens a graphical DatePicker popover with max = today]
  • Search field
  • Trailing: the prominent “Open flight” / “Open %u flights” (flight.button.open). “Upload photos” exists but is never shown.

Search:

  • Prompt “Search %@” (common.search.prompt.service).
  • Token search over pilot, date, glider, site, country and the formatted distance and speed.
  • Covers loaded pages only.

Opening:

  • Calls openServiceFlights. More than one flight without Pro shows the paywall.
  • The map is pushed over the browser, so Back keeps the selection.

Android:

  • PullToRefreshBox with the M3 Expressive LoadingIndicator (the morphing-shape indicator) as the refresh and first-load indicator.
  • Paging 3 LazyPagingItems.
  • DatePickerDialog.
  • A HorizontalFloatingToolbar bottom bar with an embedded search field, and a FAB-styled “Open N flights”.
  1. Logbook save (Persistence/.../CoreFlight+CoreData.swift):
    • If a flight exists with identical raw IGC text, the existing record is returned silently and isRead is untouched.
    • If a recent-only record with the same content exists, it is promoted to the logbook.
    • Otherwise a new record is created.
    • There is no “duplicate” message.
  2. Background deduplication (DataManager.deduplicate, persistent history / CloudKit): same filename + same content keeps the oldest record.
  3. On the map: the same Flight.id triggers “This flight is already shown on the map.” (error.flight.label.duplicate).
PathProgressSuccess
Files / drop → logbooknonenothing opens; flights arrive unread. A sidebar-row drop switches to that list.
Map (outcome B)Centred glass pill “Loading flight” / “Loading flights (%u / %u)”The map opens with the first flight selected.
BluetoothDeterminate bar + ”%@ remaining” + NMEA footer + notifications“Complete” alert
FLARMIndeterminate spinner + status + connected device“Complete” alert
Services listFull-screen spinner, pull-to-refresh–

Error presentation (ImportScreens/Hosting/Alerts/UIViewController+Error.swift):

  • One error: an alert with title = the file name, message = the description, and “Ok”.
  • Several errors: a form sheet ErrorListView with Close, a big red exclamationmark.triangle.fill and “There is a problem loading these files.” (error.label.multiple). Each row is the file name over its message.
  • Error cases: importFailed, loadingFailed, duplicateFlight.
  • Android: AlertDialog for one error; a ModalBottomSheet or full-screen dialog listing them for several.

4.7 Pro gating (Packages/Preferences/.../Pro/ProFeature.swift)

Section titled “4.7 Pro gating (Packages/Preferences/.../Pro/ProFeature.swift)”
  • isUnlocked(f) = !isFreeTierEnabled || isSubscribed
  • allowsFlights(count) = count <= 1 || isUnlocked(.severalFlights)

Gated:

  • Every external entry waits for isAppOpen (hard paywall when the free tier is off).
  • .severalFlights covers any multi-flight map:
    • A batch from a link or file is trimmed to its first flight, and the paywall is shown.
    • Adding to an occupied map is gated; Replace is allowed.
  • .taskOverride (CUP drop on a map).
  • .recording.
  • .uploads.

Not gated:

  • Files and drop import.
  • Bluetooth and FLARM.
  • Browsing services.
  • Opening a single flight.

4.8 Device send (shares the import screens)

Section titled “4.8 Device send (shares the import screens)”

Entry points:

  • Task menu › “Send to” › “Bluetooth device” / “Wi-Fi device”.
  • Settings › Flight declaration.

SendDataType: .declaration, .task, .flight.

Bluetooth task flow:

  1. “Reading current declaration…” (DECL,R,1,6).
  2. If the pilot or glider is missing, the alert “Missing information” / “Do you want to add your pilot information?” appears.
  3. “Transferring task declaration…” writes DECL,W with 6 pilot lines + the NANO-encoded task.
  4. A determinate bar.
  5. “Complete” / “The task declaration is successfully transferred.”

FLARM: FlarmConfigRequest or FlarmTaskRequest.

  • Open-in routes the whole batch by the first URL’s extension.
  • Duplicate logbook import is silent.
  • The Bluetooth flight list has no empty state, and decode errors are only logged.
  • WeGlide exposes only “Your flights”. Photo upload is disabled.
  • A code comment claims a flight dropped on Recents “opens”, but it only imports.

5. Flight map screen: map, barogram, replay, compare, ranking

Section titled “5. Flight map screen: map, barogram, replay, compare, ranking”

Note: inside §5, references such as “§2.3” or “§4.4” point to this section’s own subsections (§5.2.3, §5.4.4, …).

Source root: /Users/fousa/Projects/vulture-ios. All paths below are relative to it. English strings come from Modules/App/Resources/Localizable.xcstrings (marked [App]) and Packages/DesignSystem/Sources/DesignSystem/Resources/Localizable.xcstrings (marked [DS]). Many flight.button.* keys live only in the App catalog, not the DesignSystem one, so the Android port needs both.

Main files

  • Modules/App/Sources/Map/Flight/FlightMapViewController.swift: bar, menus, compare, ranking, satellite, drop handling.
  • FlightMapViewModel.swift (+ +GraphMenu, +Ranking): state, playback, alignment, loading.
  • FlightMapView.swift: the panel card, the wind and phase pills, the show-graph button, the drop overlay.
  • Panel/**: the flight cards, the barogram, the play/zoom/unit controls.
  • DataSource/**: Mapbox layers.

  1. 3D does not exist. Docs/3d-map-investigation.md is “investigation only, nothing implemented”. There is no 3D toggle, no 3D camera and no ModelLayer. The only part of its §11 that shipped is the 2D race ranking.
  2. Track colouring has one mode: altitude. It is either the altitude gradient or the plain flight colour, chosen by the setting “Show colored track”. There is no vario, speed or phase colouring of the track.
  3. The barogram shows one series at a time. You pick it from the unit menu. It is not altitude plus an overlay. When a non-altitude unit is picked, the GPS altitude is drawn as a grey backdrop behind it.
  4. The real default play speed is 10x. The code says Defaults[.playSpeed] ?? .x25, but an Int-backed enum reads a missing key as rawValue 0, which is .x10. The graph unit defaults to Pressure altitude for the same reason.
  5. The flight map has no legend. LegendDock and LegendView are only used by TaskMapView and InFlightMapView (weather legends).
  6. The map style is not switched from this screen. It is a Settings value: Adaptive, Outdoors, Satellite, Streets, Standard. The only map-overlay toggle in the bar is the SkySight satellite cloud overlay.
  7. Flights from different days overlay by time of day. Times are seconds since midnight UTC (rawTime), so two flights flown on different dates share one time axis.
  8. “OLC/WeGlide scoring legs” is really the app’s own distance optimisation (credited “Koen Peeters”), plus the declared-task attempt. See §6.

5.1.1 Structure (FlightMapView.swift, PanelContentViewController+Layout.swift)

Section titled “5.1.1 Structure (FlightMapView.swift, PanelContentViewController+Layout.swift)”
  • Full-screen map. The list’s bottom toolbar and the tab bar are hidden while the map is shown. The large title is turned off.
  • Bottom panel. A floating glass card (UIGlassEffect):
    • Margin: FloatingPanel.margin = 8 from the bottom and sides. All four corners are rounded (FloatingPanel.corners).
    • Sides: the card clears a floating sidebar on iPad and Mac (FloatingPanel.insets(in:)).
    • Height: 40% of the view height, or 45% when vertical size class is compact (phone in landscape).
    • Resting states: two only, .all (shown) and .none (hidden). They are not Material detents.
    • The state is persisted in Defaults[.panelState]. The default is .all.
    • Drag: a pan on the card moves it by frame.
      • On release, a flick faster than 500 pt/s decides by direction (down hides, up shows).
      • Otherwise the card goes up if its top is above the midpoint (half the panel height above the floor).
      • While dragging, the graph fades below the midpoint (alpha 0, 0.35 s).
      • The pan waits for the graph-handle gesture, and for a two-finger scroll in a zoomed graph.
    • Animation: spring 0.5 s, damping 0.7, initial velocity 0.4. It is skipped with Reduce Motion.
  • Panel contents, top to bottom:
    1. Dragger image: tertiary tint, 3 pt from the top.
    2. Flights strip: 10 pt from the top. See §1.4.
    3. Barogram container: 8 pt side insets, top corners rounded 16.
    4. Control row, 34 pt high:
      • [Play button] leading at 8 pt.
      • [Reset zoom] 8 pt after it.
      • [Graph unit button] trailing at 8 pt, at most 50% of the width, truncating its tail, with a popup chevron.
      • In compact-vertical (phone landscape) the whole row is hidden and the graph runs to the bottom.
  • Floating pills above the panel. They ride the panel’s top edge at 8 pt, and are clamped to the safe area when the panel is hidden:
    • Wind pill, bottom-leading. 16 pt inset (8 pt on regular width). Contents:
      • A rotating wind arrow image; rotation = direction − 180°.
      • The speed in caption1, secondary colour.
      • The bearing "NNN°" in caption2, tertiary colour.
      • It is shown only when the setting “Show wind indicator” is on and a wind sample exists at the current fix.
      • A11y label “Wind speed”; value ”%@ from %@”.
    • Phase pill, bottom-trailing. A glass capsule button, 102×41:
      • Leg: symbol arrow.down.right; title = the leg’s average speed; subtitle = the leg distance.
      • Thermal: symbol arrow.counterclockwise (left turns), arrow.clockwise (right) or circle (mixed); title = average climb; subtitle = altitude gained.
      • A11y label “title, subtitle”.
      • Tap:
        • On a leg: highlight the leg on the map and zoom to it.
        • On a thermal: go to the thermal’s centre fix and centre the map.
        • Between phases: go to the nearest thermal by time.
    • Show-graph button, bottom-trailing. A round glass MapButton, symbol dock.arrow.up.rectangle, a11y “Show graph”. It occupies the phase pill’s spot only while the panel is hidden; the two cross-fade.
    • Wind and phase pills are hidden in compact-vertical (phone landscape).
  • Loading HUD. A centred glass capsule with a spinner and text. The initial text is “Loading map”. See §7.
  • Drop overlay (drag and drop of IGC/CUP files):
    • Two stacked glass targets:
      • Add: plus.circle.fill, “Release to add the flight(s)” / “The flights already shown stay”.
      • Replace: arrow.triangle.2.circlepath, “Release to replace the flight(s)” / “Only the dropped flights are shown”. Shown only if flights are already loaded.
    • A task drop shows one target: flag.pattern.checkered, “Release to use this task” / “Every flight on the map gets this task”.
    • The armed target is tinted in brand orange (alpha 0.15 light, 0.28 dark). The unarmed one is scaled to 0.96 at alpha 0.55.

5.1.2 Navigation bar (FlightMapViewController.invalidateBarButtonItems, invalidateTitle)

Section titled “5.1.2 Navigation bar (FlightMapViewController.invalidateBarButtonItems, invalidateTitle)”

Title. navigationItem.style = .editor.

  • The title is the flight’s name, with the glider and date as subtitle. It is shown only on regular width, or compact height (phone landscape).
  • On a narrow upright phone the title is empty, because the buttons need the room.
  • With several flights the title is “%u flights” (flight.label.subtitle plural), with the first flight’s date as subtitle.

Right bar items, listed from the right edge leftward:

ItemSF symbolShown whenA11y / title
Info (flight detail)info (Mac info.circle)content available (a flight is selected)“Open detail” (accessibility.button.menu.detail)
Comparearrow.left.arrow.rightmore than one flight“Compare” (flight.button.compareflights)
Debrieftext.bubble, titledregular width and a flight is present“Debrief” (flight.button.debrief)
Rankinglist.number, titledregular width and the ranking is available (§4.4)“Ranking” (flight.button.ranking)
Satellite cloudsicloud.and.arrow.down (tiles not downloaded) / cloud (off) / cloud.fill (on); while downloading, a progress ring drawn around a small cloudalways; enabled only if content is available, the archive has imagery for a flight on the map, and no download is in progress“Toggle weather overlay” (accessibility.button.satellite); the value is “NN%” while downloading

Overflow menu (the system ”…” setOverflowItems, rebuilt each time it opens). Sections in order (overflowItems):

  1. “Go to” submenu (menu.title.goto, symbol location). Each child is conditional:
    • “Glider” (paperplane): the selected flight has a current fix.
    • “Flight” (point.topleft.down.curvedto.point.bottomright.up): a flight is selected.
    • “Task” (skew): the selected flight has a task.
    • “Nearest thermal” (tornado): the current fix exists and there are thermals. This opens the thermal sheet for the nearest thermal.
    • “Selected leg” (arrow.right): a leg highlight is active.
    • “Infringements” (square.stack.3d.up): an infringement highlight is active.
  2. Task group (inline):
    • “Align start” (clock.arrow.2.circlepath): more than one flight and not already aligned.
    • “Override task” (link): any flights. Pro feature .taskOverride.
    • “Reset override” (arrow.counterclockwise, destructive): a task is selected.
    • “Reset alignment” (clock.badge.xmark, destructive): aligned.
    • “Clear leg” (xmark, destructive): a leg highlight is active.
    • “Clear infringements” (xmark, destructive): an infringement highlight is active.
  3. “Debrief” (text.bubble): compact width only. On regular width it is a bar button.
  4. “Ranking” (list.number): compact width only, when available.
  5. “Flight Coach” (sparkles): disabled by a hard flag (isFlightCoachEnabled = false). Ignore.
  6. Flight group:
    • “Save to flight log” (folder.badge.plus): the flight is not in the logbook.
    • “Mark flight” submenu: Favorite / Contest / Field landing toggles. Logbook flights only.
    • “Edit flight” (square.and.pencil): logbook flights only.
  7. Share group:
    • “Share” submenu (square.and.arrow.up):
      • “IGC File” (doc): share sheet with the IGC.
      • “Map Image” (photo): a map snapshot composed with a strip showing the competition ID (or registration) in the flight colour, the date · time, and at the current fix: speed, “Vario”, “MSL”, and “AGL” when terrain is available.
    • “Upload flight” submenu (arrow.up): SkyLines / WeGlide / Charron, only for connected services.
  8. “Terrain” (map): reloads the terrain elevation. HUD “Loading terrain…”.
  9. “Settings” (gear).

Detail/Info presentation (MapInspector). On iPhone and iPad it is a popover anchored on the Info button, which adapts to a sheet on compact width. On Mac it is an inspector column. It is rebuilt each time it opens (DetailPopupController).

“Companion” presentations (Debrief, Ranking; present(companion:)):

  • iPhone (compact): a card over the map (SheetController). Height is 40% of the screen, or 55% in landscape; at least the content’s minimum; at most 60%. Drag to dismiss when moved more than half its height or flung down faster than 1500 pt/s.
  • iPad (regular): a popover of 400×600 anchored on the overflow button. The sidebar hides, and the map stays interactive (passthrough).
  • Mac: inspector column or form sheet.

Only one card is up at a time. The thermal, airspace, debrief and ranking cards replace each other.

Other screen behaviour:

  • When there are flights, the map shows all of them framed, with edge padding: top max(safe area, 56) + 16; sides 16; bottom = panel height + 8 + 16.
  • Playback pauses when the screen disappears or the app goes to the background.
iPhone portraitiPhone landscape (compact-V)iPad / regular width
Nav titlehiddenshownshown
Debrief / Rankingoverflow menuoverflow menubar buttons (with title)
Panel height40%45%40%
Control row (play / zoom / unit)shownhiddenshown
Wind and phase pillsshownhiddenshown (wind inset 8)
Flights strip height70 (multi) / 30 (single)35 (multi) / 30 (single)70 / 30
Flight card value rowsaltitude, vario, speed + datealtitude only, no dateall
Graph grid interval“large”“small” (isSmall = isPhoneInLandscape)large
Reset zoom buttonicon only–icon + “Reset zoom”
Companion (Debrief / Ranking)cardcardpopover with map passthrough
Compare screenmodal sheet (automatic)–full screen (popover delegate: regular width becomes fullScreen)
Compare columns visible22up to 6

5.1.4 Flights strip in the panel (Panel/Flights/PanelFlightsViewController.swift, Cells/*)

Section titled “5.1.4 Flights strip in the panel (Panel/Flights/PanelFlightsViewController.swift, Cells/*)”

Horizontal strip. It is not paged; it scrolls only when there are several flights.

One flight: a “line” cell (height 30) in one row:

  • The registration in the flight colour (title2, 22 pt, max 26).
  • A small date, or the race place, at label alpha 0.3.
  • Equal-width values with small units:
    • AGL height, as “123 m AGL”. Only shown when the cell is at least 450 pt wide.
    • Altitude.
    • Vario.
    • Groundspeed.
    • Distance traveled. Only at 450 pt or wider.

Several flights: chip cards (estimated width 150, corner radius 16, 8 pt spacing, 10 pt section inset):

  • Left column (55 pt): the registration (title2, 22 pt) over the date (caption2). The date is replaced by the race place, e.g. “3rd · −3.2 km/h”, when a ranking exists.
  • Right column: altitude, vario and groundspeed values (subheadline 15 pt), each with a unit label (caption2 11 pt).
  • Selected card: background = flight colour, text = inverseColor (a matching readable text colour), unit alpha 0.7.
  • Unselected card: background tertiarySystemGroupedBackground; registration in the flight colour; values in label colour at alpha 0.8; units at alpha 0.3.
  • Missing values show ”—”.

Interactions:

  • Tap a card: selects the flight, with a tap haptic (except on first load).
  • Long-press / context menu on a non-selected card (multi only): “Remove from map” (trash, destructive).
  • Drag to reorder (multi only). The selection follows the move.

Live updates. The readouts update on every position change: scrub, playback or step.

A11y:

  • Label ”%@ on %@” (registration, date or place).
  • Value “Height %@%@, vertical speed %@%@ and speed %@%@”.

Files:

  • Panel/Graph/PanelGraphView.swift, +Gestures, +Accessibility
  • GraphDataSource.swift
  • Views/GraphFlightDataView.swift, GraphBitmapRenderer.swift, GraphHandleView.swift, GraphHandleMetadataView.swift, GraphMetadataView.swift, GraphValueView.swift

One unit is plotted at a time, for every flight. The unit is a global, persisted preference (graphDataUnit, default Pressure altitude).

The unit button (PanelContentViewController+Graph.swift):

  • Shows the unit’s SF symbol and its title, with a popup chevron. Tapping it opens the menu.
  • A11y “Graph settings”, hint “Select a different graph type”.
  • The checked item is the current unit.

Menu structure (FlightMapViewModel+GraphMenu.swift):

  1. Inline group: “Average task speed” (only if the selected flight attempted a task), then “Average speed”.
  2. Plain items: “Wind speed”, “Vertical speed”, “Groundspeed”.
  3. Submenu “Altitude” (mountain.2): first any altitude-category extension the IGC carries (“Total energy”), then “GPS altitude”, then “Pressure altitude”.
  4. One submenu per extension category present in the IGC, in this order:
    • Speed (hare): “Airspeed”
    • Atmosphere (wind): “Temperature outside”, “G-force”, “Vertical acceleration”, “Air movement”
    • Attitude (gyroscope): “Pitch angle”, “Roll angle”
    • Engine (engine.combustion): “Engine RPM”, “Fuel level”, “Fuel flow”, “MOP”, “Flaps”
    • Temperature (thermometer): “Temperature exhaust”, “Temperature motor”, “Temperature controller”, “Temperature jet pipe”
    • Electrical (bolt): “Electrical voltage”, “Electrical current”, “Battery left”
    • Biometric (heart): “Heart rate”, “Blood oxygen level”

There is no ENL series. The engine level appears only as a shaded fill (§2.3). There is also no separate “ground elevation” series: terrain is drawn as a fill under altitude units.

Unit icons (caption2 symbol config, from Packages/Preferences/Sources/Preferences/Models/GraphDataUnit.swift):

UnitSymbol
GPS altituderotate.3d
Pressure altitudebarometer
Groundspeed, Airspeedhare
Vertical speed, Total energytornado
Wind speedwind
Average speed, Average task speedspeedometer
Engine RPMarrow.triangle.2.circlepath
Temperaturesthermometer
Battery leftbattery.50
Electrical voltagebolt.batteryblock
Electrical currentbolt
Fuel levelfuelpump
Fuel flowalternatingcurrent
G-force, Vertical accelerationdot.arrowtriangles.up.right.down.left.circle
Flapstrapezoid.and.line.vertical
MOPtropicalstorm
Air movementarrow.up
Pitch anglecircle.and.line.horizontal
Roll angleline.diagonal
Heart rateheart
Blood oxygendrop

Units of measure follow the user’s preferences:

  • altitude for the altitude units
  • speed for groundspeed, airspeed and the average speeds
  • vertical speed for vario, total energy and air movement
  • wind-speed unit for wind
  • temperature unit for the temperatures
  • g for G-force and vertical acceleration
  • V, A, L, % for electrical, fuel and battery
  • unitless for flaps, RPM, MOP, attitude, heart rate and SpO₂

Readout formats (unitFormat):

UnitFormat
Pressure altitude, terrain available"%0.f %@ AGL" (the plotted Y is still pressure altitude, but the readout shows AGL)
Altitudes, speeds, wind"%0.f %@"
Vario, total energy"%0.2f %@"
G-force"%0.1f %@"
Temperatures"%0.f °%@"
Battery"%0.2f %@"
Attitude"%0.f °"
SpO₂"%0.f%%"
Flapsthe flap name string from the IGC

Wind is time-based. It uses the last wind sample at or before t.

5.2.2 Axes (GraphDataSource, GraphMetadataView)

Section titled “5.2.2 Axes (GraphDataSource, GraphMetadataView)”

X axis. Time runs from the earliest start to the latest end across all flights, including alignment offsets. There are no time labels on the X axis.

Y axis:

  • Value labels down the leading edge: a 50 pt label column plus 4 pt spacing. Text is “value unit”, caption2, secondaryLabel, right-aligned.
  • Horizontal grid lines in tertiaryLabel, 1 px.
  • Labels are thinned (every 2nd, 4th…) when they are closer than 22 pt. The lowest label is always kept.

Grid interval (large / small):

UnitLargeSmall
Altitude, m5001000
Altitude, ft15003000
Speeds50100
Vario, m/s510
Vario, ft/s1000500
Wind1020
G-force12
Heart rate4010
Otherssee GraphDataSource.swift
  • The interval is ×2 when there would be more than 10 lines, and ×4 when more than 14.
  • For altitude with terrain, the range starts one grid line below the lowest point. If the lowest point is below the first grid line, the interval switches to 100.
  • Vario is clamped to between −10 and +14. Total energy has a floor of at most −5.

Content insets: top 36 (room for badges), bottom 16, leading and trailing 8.

5.2.3 What is drawn per flight (GraphBitmapRenderer, GraphFlightDataView)

Section titled “5.2.3 What is drawn per flight (GraphBitmapRenderer, GraphFlightDataView)”

Unselected flights:

  • A trace in secondaryLabel, width 1, alpha 0.3.
  • If no flight is selected, all flights count as “selected”.

Selected flight (brought to front):

  • Trace: the flight colour, width 2.
  • Terrain fill (only for altitude units when elevation is available): fill Elevation #C3AC93 light / #644E35 dark; border #AD8F6C / #8A6C49, 1 pt. Without terrain, a 1 px baseline in the elevation-border colour is drawn at the bottom.
  • GPS altitude backdrop (only for non-altitude units): a tertiaryLabel fill at alpha 0.3, scaled to the GPS altitude range.
  • Thermal bands: full-height vertical bands from thermal entry to exit. Colour “Thermal” #488165 light / #69AB8B dark, alpha 0.3. Shown when the “Show thermals” setting is on.
  • Thermal-attempt bands: #FF4139, alpha 0.3. Shown when the “Show thermal attempts” setting is on.
  • Engine run fill: Engine #F6B91E at alpha 0.3. The height is engineLevel/1000 of the graph height, wherever engineLevel > 200 + the flight’s engine threshold.
  • Highlight stretch (a leg or infringement): stroke 2.5 pt in Baro Highlight #D5A359, or Infringement red #ED5656.

So there is no circling/gliding colouring of the trace. Phases appear as the thermal bands.

Badges along the top (selected flight only):

  • Task attempt (requires “Show task” and “Show optimized task”):
    • Green (Success #468262 / #519672) circular badges with white text.
    • Text: “S” for the start, “1”, “2”… for turnpoints, then “F” if completed or “X” if not.
    • A dashed vertical line [3,4] at width 1.5 under each badge.
  • Optimised track (requires “Optimized track” not None):
    • Brand orange #DF774A badges “S”, “1”…, “F”, with dashed lines [4,3].
    • A badge is shifted one row up if it would overlap a green one.
  • AAT (requires “Show task” and “Show minimum AAT duration”):
    • A label “AAT: h:mm” in #D5A359, centred over a 2 pt horizontal line at the top.
    • The line runs from the task start to the start plus the minimum AAT duration.

Flight events (only for altitude units when the setting “Show flight events” is on):

  • Dots of radius 6, stroke 2, fill at 20% alpha:
    • Release: systemGray
    • PEV: #D5A359
    • Furthest: #5C7EA1
    • Lowest: #F61D24
    • Highest: #4C9172
  • Each dot has a labelled pill: “Release”, “PEV”, “Furthest”, “Lowest”, “Highest”, in white caption2 text on the event colour, with a hole cut out for the dot.
  • The pill fades in when the handle or the pointer hover is within 10 pt of the dot.
  • A pill flips to the left side if it would overflow the right edge, or would be covered by the handle’s value label.

5.2.4 Scrubbing: handle, readout and map sync (GraphHandleView, GraphHandleMetadataView, PanelGraphView.updateHandleRelativePosition)

Section titled “5.2.4 Scrubbing: handle, readout and map sync (GraphHandleView, GraphHandleMetadataView, PanelGraphView.updateHandleRelativePosition)”

The handle is a vertical line in Handle orange #DF774A, 2 pt, the full height of the graph. It is hidden until the user first interacts.

Gestures:

  • Tap anywhere on the graph: jumps the handle there.
  • One-finger pan: drags the handle (direct touch and pencil).
  • When zoomed (scale > 1): a pan only grabs the handle if it starts within ±40 pt of it; otherwise it scrolls the zoomed graph.
  • A pointer (trackpad or mouse) drag moves the handle.
  • Clamping: past the left edge → x = 1. Past the right edge → x = width − 1 and “reached end”, which pauses playback.
  • Each step plays a selection haptic (generateSwipeFeedback).
  • While the handle is active, the panel’s drag pan is disabled (isHandleActive → togglePanGesture).

Readout. A dot of radius 6 in handle orange sits on the selected flight’s trace, with a value bubble beside it (GraphValueView):

  • Background handle orange, corner radius 8, white text.
  • Title (footnote): the formatted value.
  • Subtitle (caption2, 80% alpha): the fix time HH:mm:ss. When aligned by task start it shows “T+h:mm:ss” since the earliest shifted task start.
  • The bubble sits 8 pt to the right of the handle, or flips to the left near the right edge. It is 4 pt from the top.

What a handle move does (updateHandleRelativePosition):

  • The time t is computed from the x position.
  • For every flight, the fix at t (from the graph’s per-flight, offset-shifted time map) is looked up.
  • The fixes go to viewModel.fixesSubject, which drives all of:
    • each flight’s currentFix
    • the glider markers on the map, moved and rotated to the fix bearing
    • the flight cards’ values
    • the wind pill and phase pill
    • the airspace sheet’s location marker
    • the satellite cloud frame nearest t
    • the race ranking
  • The map camera does not follow while scrubbing, except via the “Pin current position” setting (§3.6).

Keyboard (hardware keyboard / Mac):

KeyActionStrings
Spaceplay / stop“Start/stop playback”; menu titles “Play” / “Stop”
← / →∓1 second“Go backward” / “Go forward”
Ctrl-← / Ctrl-→handle ∓2 points of graph width“Jump backward” / “Jump forward”
⌥← / ⌥→previous / next thermal“Select the previous thermal” / “Select the next thermal”
⌘← / ⌘→previous / next flight“Select the previous flight” / “Select the next flight”
⌥⌘SCompare
⌥⌘RRanking
⌥⌘FFlight
⇧⌥⌘FShow all flights
⌥⌘GGlider

Keys are defined in Modules/App/Sources/Application/Commands/AppCommand+Key.swift.

5.2.5 Zoom (PanelGraphView+Gestures.swift)

Section titled “5.2.5 Zoom (PanelGraphView+Gestures.swift)”
  • Pinch zooms horizontally only, from 1× to 10×, anchored at the pinch point. During the pinch the paths are only rescaled; they are recomputed on release.
  • Scroll-wheel or trackpad vertical scroll also zooms (sensitivity 0.01 per pt), anchored at the hover point.
  • When zoomed, the graph scrolls horizontally (no scroll indicator).
  • “Reset zoom” button (arrow.down.right.and.arrow.up.left, “Reset zoom” / a11y “Reset zoom”): fades in (0.15 s) when the scale is not 1 and sets the scale back to 1.
  • There is no range selection. Highlighting a range comes only from legs and infringements (§3.4).

5.2.6 Accessibility (PanelGraphView+Accessibility.swift)

Section titled “5.2.6 Accessibility (PanelGraphView+Accessibility.swift)”
  • Label: “Barogram”. Once data exists it becomes “%1$@ over %2$@, from %3$@ to %4$@” (unit title, duration, min and max with units).
  • Hint: “Tap to show your location on the map”.
  • Audio graph (AXChartDescriptor):
    • X axis titled “Time”; Y axis titled with the unit.
    • One series per flight, resampled at 60 points.
    • The series name is the pilot, or the 1-based index.
  • Drawn text follows Bold Text (semibold caption2).

5.3 Map (DataSource/**, Rendering/Layers/**, Extensions/Flight+Polylines.swift, UIColor+Theme.swift)

Section titled “5.3 Map (DataSource/**, Rendering/Layers/**, Extensions/Flight+Polylines.swift, UIColor+Theme.swift)”

Every flight gets a thin line in its palette colour. Width is interpolated by zoom: 0.8 at z9 to 1.5 at z14.

The selected flight gets an extra layer:

  • “Show colored track” ON (the default): an altitude gradient, from low = red to high = blue. There are 11 stops:

    #FF0000 #FF6300 #FFC700 #CCFF00 #69FF00 #03FF00 #00FF66 #00FFC9 #00CCFF #0069FF #0000FF

    The gradient is bucketed in 0.5% steps of the flight’s pressure-altitude range. Each segment takes the colour of the fix it ends at. The source is not simplified. Width 1.6 at z9 to 3.0 at z14.

  • OFF: a plain line in the flight colour darkened, width 1.6 to 3.0.

Optimised track (setting “Optimized track”; default the Free 5 turnpoint distance): a dotted line [1,2] in brand orange #DF774A, width 1.6 at z8 to 3.0 at z14.

Optimised task (the best task attempt; requires “Show task” and “Show optimized task”): a dotted line in success green #468262 / #519672.

5.3.2 Task overlay (requires “Show task”, default on)

Section titled “5.3.2 Task overlay (requires “Show task”, default on)”
  • Turnpoint observation zones: fill alpha 0.2 and border alpha 0.8, both from per-feature colours; minZoom 6; border width 1.5 to 3.
  • Legs: a dotted line [1,2] in Task blue #5C7EA1.
  • There are no leg distance labels on this map.
  • A task dropped onto an empty map is drawn standalone and centred.

Thermals (requires “Show thermals” and/or “Show thermal attempts”, both on by default):

  • Drawn as circle polygons filled at opacity 0.4, minZoom 8.
  • Colour: if vario < 0, Thermal Sink #F61D24. Otherwise a gradient from Thermal Bad #F6B91E to Thermal Good #98F61E, by the thermal’s relative climb ratio.
  • A filter applies the show-thermals and show-attempts switches separately.

Events (IGC events such as takeoff, release, landing, PEV, start, finish, …):

  • White circles with a grey #5F5F5F stroke; radius and stroke 1.6 to 3.
  • A name label to the right, 12 pt, fading in between z7 and z8. MinZoom 9.

Photos (setting “Show your photos”; logbook flights only):

  • Library photos taken during the flight, clustered (radius 35).
  • Circles of radius 10 in the flight colour, plus a symbol.
  • Leg highlight: a 1.6 to 3 line in #D5A359 with a darker 2.6 to 4 border. The camera frames it.
  • Infringement highlight: the same, in red #ED5656.
  • Both are mirrored on the graph (§2.3).
  • Selecting another flight clears the leg highlight.

5.3.5 Glider marker (Rendering/Views/GliderAnnotationView.swift)

Section titled “5.3.5 Glider marker (Rendering/Views/GliderAnnotationView.swift)”

One marker per flight, placed at the flight’s current fix. It appears only after the first scrub or playback step.

Look:

  • The sport icon (glider, delta or parapente; from the flight’s detected sport), tinted with the label colour.
    • Forced to the light variant on Outdoor and Streets maps, and the dark variant on Satellite.
  • Rotated to the fix bearing minus the map bearing.
  • Scales with viewing distance.
  • A registration label pill (caption1) 10 pt above the icon:
    • Selected: pill filled with the flight colour, inverse text, icon alpha 1, brought to front.
    • Unselected: clear pill, text in the flight colour, icon alpha 0.5.

Tap a glider: selects its flight, with a light haptic.

A11y: “Glider %@” (or “Glider”), button trait.

Map tap:

  1. Photos are checked first, with a ±22 pt touch rect. A hit opens a full-screen dark photo pager.
  2. Otherwise thermals are checked; a hit opens the thermal detail sheet (Pro .thermalAnalysis). The sheet’s graph scrub moves the glider without moving the camera.

Tapping the track itself does nothing.

Other shared gestures (MapViewDataSource+Gestures):

  • Long-press, or a secondary click: airspace query sheet.
  • Two-finger long-press: measure distance.
  • A left-edge guard keeps the swipe-back gesture working.

Camera:

  • On load: frames all flights.
  • “Go to” actions:
    • Glider: radius 5 km.
    • Fix or thermal centre: radius 2 km.
    • Flight, task, leg, all flights: frame the bounds.
  • “Pin current position” setting (isPinned): after each position update, if the selected glider is outside the visible rect inset by the edge padding, the camera recentres on it (radius 10 km). This is the only camera-follow behaviour.

5.3.7 Satellite cloud overlay (DataSource/FlightMapViewDataSource+Satellite.swift, FlightMapViewModel “Satellite”)

Section titled “5.3.7 Satellite cloud overlay (DataSource/FlightMapViewDataSource+Satellite.swift, FlightMapViewModel “Satellite”)”

What it shows. SkySight satellite imagery tiles, one raster layer per timestamp at the archive’s interval.

  • The shown frame is at opacity 0.5.
  • ±2 neighbouring frames are kept warm at opacity 0.01.
  • The shown frame follows the scrub and playback time: the nearest frame to the selected flight’s UTC time.
  • A cold frame is shown on the next map idle, or after 1 s.

Tap the cloud button:

  1. Without Pro, the paywall appears (.skySight).
  2. Without a SkySight login, the sign-in screen appears; after sign-in the tap continues.
  3. If tiles are missing, they are downloaded. The progress ring fills (accessibility value “NN%”) and the overlay turns on when done.
  4. If tiles are present, the overlay toggles.

Auto-download happens when the panel first presents, if the SkySight “auto download satellite” preference is on.

Reset. The overlay is turned off whenever flights are added or removed.

The initial frame is the selected flight’s temporal midpoint.

Section titled “5.3.8 Map-related settings (live-updating, in the Settings screens, not on the map)”
Setting (key)DefaultEffect
“Show colored track” (map.settings.coloredtrack)onaltitude gradient vs plain darkened colour (§3.1)
“Pin current position”offcamera follow (§3.6)
“Show wind indicator”onwind pill
“Show your photos”offphoto markers
“Show thermals”onthermal circles and graph bands
“Show thermal attempts”onattempt circles and graph bands
“Show flight events” (map.settings.optimization.altitudegained)onevent dots and pills on altitude graphs
“Optimized track”Free 5 turnpointspicker: None / Straight / Out & return / Triangle / Free 3 tp / Free 5 tp; drives the map line and the orange graph badges
“Show task”ontask overlay
“Show optimized task”ongreen task-attempt line and graph badges
“Show minimum AAT duration”offAAT line on the graph
“Map style”–Adaptive / Outdoors / Satellite / Streets / Standard

5.4 Replay / playback, alignment, race ranking

Section titled “5.4 Replay / playback, alignment, race ranking”

5.4.1 Controls (PanelContentViewController+Playback.swift, Views/ToggleButton.swift)

Section titled “5.4.1 Controls (PanelContentViewController+Playback.swift, Views/ToggleButton.swift)”

Stopped (ToggleButton):

  • Icon play.circle, title “Play”, a11y “Play”.
  • Tapping opens the speed menu (it is the primary action). The menu has large elements:
    • “10x”, “25x”, “50x”, “100x”, “200x”
    • Each has the subtitle “Duration: %d min %02d sec”, the replay length = the full span ÷ the multiplier.
  • Picking a speed starts playback at that speed and persists it.

Playing:

  • Icon stop.circle, title “Stop (25x)” (the current speed), a11y “Stop”. Background quaternaryLabel, corner radius 10.
  • A tap stops playback.
  • A long-press opens the same menu, which changes the speed without stopping.

5.4.2 Playback engine (FlightMapViewModel.play/playToNextTime)

Section titled “5.4.2 Playback engine (FlightMapViewModel.play/playToNextTime)”
  • Driven by the display frame clock (CADisplayLink). The first frame only sets the baseline.
  • Each frame adds realElapsed × multiplier to an accumulator. Whole seconds are applied as time += n.
  • Playback starts at the current time, or at the earliest start if the handle has never moved.
  • At the end time it pauses. The handle reaching the graph’s end also pauses.
  • Each tick computes the fix at time − timeOffset for every flight. That moves the gliders, cards, pills, graph handle (positionHandle), ranking and satellite frame.
  • Playback pauses when the screen disappears or the app goes to the background.

Default: real time of day. Every timeOffset is 0.

  • One shared axis in seconds since midnight UTC.
  • A flight’s glider and readout disappear (fix = nil) outside its recording.

“Align start” (overflow menu):

  • Requires a task selected through Override task. Otherwise an alert appears:
    • Title “Select task?”
    • Message “A task needs to be selected to align the start times across flights.”
    • Buttons “Select task” / “Cancel”. “Select task” opens the task picker, then aligns.
  • Alignment sets each flight’s timeOffset = earliestTaskStart − ownBestTaskAttemptStart. Flights without a task attempt stay unshifted.
  • It then shows the alert “The task start times have been aligned.” with “OK”.
  • With alignment on, the graph readout shows “T+…”.
  • “Reset alignment” sets every offset back to 0. “Reset override” also resets the alignment.

There is no alignment by takeoff.

“Override task” (Pro):

  • Opens a task picker of tasks found in the loaded flights, with a form sheet that cannot be swiped away.
  • Applying a task:
    • hides the panel
    • shows the HUD “Overriding task” (“Resetting tasks” on reset)
    • re-enriches every flight (task and speed) in the background
    • reloads the panel and the map
    • recomputes the ranking
  • A dropped .cup file with one task applies it directly; with several tasks it opens the picker.

5.4.4 Race ranking (FlightMapViewModel+Ranking.swift, Packages/Flights/.../RaceProgress.swift, RaceRanking.swift, Packages/FlightScreens/.../RaceRanking/*)

Section titled “5.4.4 Race ranking (FlightMapViewModel+Ranking.swift, Packages/Flights/.../RaceProgress.swift, RaceRanking.swift, Packages/FlightScreens/.../RaceRanking/*)”

Availability: more than one flight on the map, and all flights share one task with more than one waypoint. Otherwise the button and menu item disappear and an open ranking closes.

Opened by:

  • the Ranking bar button (regular width) or the overflow item (compact)
  • ⌥⌘R
  • automatically after “Replay Day” from a Soaring Spot or Charron competition day (showsRankingWhenAvailable)

Progress computation (once, in the background; RaceProgress(flight:)):

  • Credited start = the first fix of the best task attempt.
  • A turnpoint is reached at its first crossing fix. Crossings stop at the first turnpoint never reached.
  • Distance flown at each fix = cumulative task distance to the next turnpoint − the straight distance from the fix to that turnpoint. Distances are measured centre to centre; AAT areas are measured through their centre.
  • The distance is monotonic: it keeps its best value and is capped at the task distance.
  • Sampling runs from the start to the finish, or to the landing fix.

Position at replay time t (each flight is read at t − timeOffset):

SituationPhaseValues
t < start, or no startnotStarted–
t ≥ finishfinisheddistance = task distance; speed = task distance ÷ task time
t ≥ last sample without finishinglandedOutdistance frozen; no speed
otherwiseracingdistance = sample at t; speed = distance ÷ max(elapsed, 15 min)

Order (RaceRanking.standings):

  1. Finished and racing flights together, by speed, descending.
  2. Then landed-out flights, by distance, descending.
  3. Then not-started flights, unplaced.
  4. Ties keep the map’s order.

Places start at 1 for everyone except the not-started.

Gaps:

  • Speed flights: −(leaderSpeed − speed).
  • Two landed-out flights: −distance.
  • Otherwise none.

Ranking view (SwiftUI RaceRankingView):

  • Title: “Ranking”. A Close button on the card and popover.
  • Section 1 header: “By average speed”, or “Aligned by task start” when aligned.
  • Section 2: “Not started”.
  • Row:
    • Place (semibold, monospaced, secondary; ”–” if none).
    • A 32 pt circle badge with the competition ID (or registration, uppercased; ”–”) in the flight colour.
    • Title: the pilot, or the ID.
    • Caption:
      • racing: “glider · distance · current climb”
      • finished: “glider · Finished HH:mm” (local time)
      • landed out: “Landed out · %@” (glider, or the distance)
      • not started: the glider
    • Trailing value:
      • a gap “−3.2 km/h” / “−12.4 km”
      • or the leader’s speed
      • or a landed-out flight’s distance
    • A checkered flag (accent colour) on finishers.
    • Landed-out rows at 50% opacity.
    • The selected flight’s row is shown as picked.
  • Rows animate when the order changes.
  • Tapping a row selects that flight on the map.
  • Mac shows column headings: “Place”, “ID”, “Pilot”, “Gap”.
  • A11y: “Place %lld, ID, title, caption, value”.
  • The ranking updates on every replay time change: play, step or scrub.
  • Panel cards show the place in place of the date: “3rd · −3.2 km/h” (localised ordinal).

  • Logbook list multi-select: “Open %u flights” (plural key flight.button.open) opens one map with all of them (ListViewController+Controllers.swift).
  • Soaring Spot / Charron days:
    • “Select all” plus open, or “Replay Day”. Flights download 3 at a time.
    • When every flight has a rank, flights are sorted by rank and recoloured by rank.
    • The day’s task is applied.
  • Drag and drop onto the map (IGC files, or logbook rows) with Add or Replace targets (§1.1).
  • A downloaded batch arriving while flights are shown asks:
    • Title “Replace?”
    • Message “Would you like to add the flight(s) to the current map or replace them?”
    • Buttons “Add” / “Replace” / “Cancel”
  • There is no in-panel “add flight” button.
  • Limit: there is no maximum count except Pro.
    • Without Pro (severalFlights) only 1 flight is allowed. Extra flights are trimmed and the paywall is shown.
    • With Pro the count is unlimited.
  • Duplicates (same flight id) are rejected, with the error “This flight is already shown on the map.”
  • Removing: context menu “Remove from map” on a non-selected card.

5.5.2 Colours (FlightMapViewModel.nextColor, DesignSystem colour sets “Flight 1”–“Flight 20”)

Section titled “5.5.2 Colours (FlightMapViewModel.nextColor, DesignSystem colour sets “Flight 1”–“Flight 20”)”

Colours are assigned round-robin in load order, cycling after 20.

#Hex#Hex#Hex#Hex
1#4A6FA06#A64F6611#AA984216#469378
2#5B5B997#A6525212#A2A14817#46938A
3#6E528F8#AA654813#88A14C18#469393
4#884C899#AA764814#689A5019#46869E
5#9C488010#AA884215#50935E20#467A9E

Each colour is the same in light and dark mode. Text on a colour uses matchingTextColor(). Ranked imports re-assign colours by rank.

There is no legend and no per-flight visibility toggle. Selection is the only emphasis: unselected flights get a thin line, a half-transparent glider and a muted grey graph trace.

5.5.3 The Compare screen (Packages/FlightScreens/Sources/FlightScreens/Compare/**, Hosting/Compare/**)

Section titled “5.5.3 The Compare screen (Packages/FlightScreens/Sources/FlightScreens/Compare/**, Hosting/Compare/**)”

Entry. The Compare bar button (more than one flight).

  • If no task override is selected, an alert appears first:
    • Title “Override task?”
    • Message “There was no common task selected, so it won’t be possible to compare task data befor the selected flights.” (typo is in the source)
    • Buttons “Select task” (opens the picker, then compares) / “Continue”.
  • Presentation: a modal navigation controller. Automatic sheet on iPhone, full screen on regular width, a separate window on Mac.
  • Title: “Compare flights”. Close button on the left.
  • Filter button on the right (line.horizontal.3.decrease, a11y “Filter comparison”):
    • Menu order: [“Task” (only if all flights share a task)], “Thermal”, “Distance”, “Performance”, “Flight”, “Leader”.
    • Multi-select checkmarks; the menu stays open between taps; at least one filter must remain.
    • All filters start on.

Grid:

  • Columns = flights; rows = metrics. Background systemGroupedBackground.
  • Left label column 100 pt wide (sticky); cell height 50; sticky header row 40.
  • Visible columns: 2 on compact width, up to 6 on regular.
  • Groups appear as rounded cards with 16 pt corners and 0.5 pt separators.
  • Cell fill: systemBackground in light mode, secondarySystemBackground in dark.
  • If all flights carry a competition rank, columns are ordered by rank.

Header capsule per flight:

  • A glass capsule button showing the competition ID (or registration), uppercased, in the flight colour, bold headline font.
  • It is prefixed with 🥇/🥈/🥉 for competition rank 1–3, when that column is not the baseline.
  • Tapping toggles “pin” (baseline):
    • The pinned column shows a pin.fill icon in brand orange.
    • It moves to the first column and stays put while scrolling sideways.
    • Its cells get a brand-orange 8% tint.
    • A11y value “Pinned”.
  • By default the first flight is pinned when there is more than one flight.

Cell content:

  • A top value in body font.
  • An optional bottom secondary value in footnote, secondaryLabel.
  • Missing values show ”—”.
  • Rows that depend on slow analyses (detour, leader rows) are blank while loading, then fill in.

Pinned mode: differences.

  • Every non-baseline cell of a rankable row shows, after the secondary value with ” · ”:
    • ▲ “+Δ” in “better” colour secondaryTint (#587899 light / #5C7EA1 dark).
    • ▼ “Δ” in “worse” colour #C2400D (rgb 0.76, 0.25, 0.05).
    • ”=” in secondaryLabel when equal.
  • Δ is formatted in the row’s unit and shown in semibold footnote.
  • “Better” follows the row’s direction; some rows treat lower as better.
  • Equality thresholds:
    • under 1 m for distance and altitude
    • under 0.05 for speed, vertical speed and percent
    • after rounding, under 1 for duration, seconds, counts, glide ratio, turn rate and points
  • Podium marks are hidden in this mode.

Unpinned mode: podium.

  • For each rankable row, the top 3 unique positive values get an icon before the value:
    • 1.circle.fill in gold #B68B1C light / #D9A621 dark
    • 2.circle.fill in silver #8D9399 / #999EA3
    • 3.circle.fill in bronze #CC8033
  • A pill behind the value in the same colour at 8% alpha.

Info popovers. Row labels that have a description are drawn with a dotted underline. Tapping one opens a 260 pt popover (arrow pointing left) with the label as headline and the description as body.

Rows (section header in quotes; data rows indented; ↓ = lower is better; ✕ = not rankable):

  • “Flight” (filter Flight)
    • Date ✕
    • Pilot ✕
    • Glider type ✕
  • “Rating” (filter Flight; only if a flight is rated). Each row has an info popover.
    • Score: a half-ring gauge (64×42, 4 pt stroke, track systemFill) drawn in the band colour, with the score number in bold rounded text inside it. Below: the band word, such as “Strong”.
    • Climbing / Gliding / Speed / Decisions: the number plus a 64×4 bar in the component’s own band colour.
    • A component row appears only if some flight has that component.
  • “Performance”
    • Flight duration
    • Engine time ↓
    • Height gain (info)
    • Average glide (info): L/D, with cruise speed below
  • “Distance optimization”
    • Maximum distance out (info)
    • Best 1h distance
    • Best 2h distance
    • Free 5 turnpoint distance
    • Triangle distance
    • Out & return distance
    • Straight distance
    • Each optimisation row shows the distance with its speed below, and ranks on speed.
    • Detour ↓ (info; from the analyses): the cruise path with thermals straightened, minus the free distance, shown only if more than 100 m.
  • “Thermals”
    • Thermals ↓ (info): count, with failed attempts below
    • Height gained / lost (info)
    • During flight ↓ (info): thermal %
    • Average vario (info)
    • Best vario (info)
    • Average turn ✕: diameter, with turn rate below
    • Avg. airspeed
    • Left turns ✕
    • Right turns ✕
  • “Leader / Leecher” (filter Leader; from LeechingAnalysis, task window if any, otherwise the whole flight). Each row has an info popover.
    • Role ✕: “Solo” if less than 60 s engaged; “Follower” if the follow ratio is at least 0.75; “Leader” if it is at most 0.25; otherwise “Racing”.
    • Lead time
    • Follow time ↓
    • Follow ratio ↓
    • Thermal delay ↓: “Ns”
    • Flew alone: the % of fixes with no other flight within 1 km and ±10 s.
    • Co-cruise detection: within 2 km, at least 240 s windows, 30 s gap bridging, ±45° rear cone and heading match, groundspeed at least 40 km/h, circling-lag correlation 5–90 s.
  • “Task” (filter Task; common task only)
    • Declared distance ✕
    • Flown declared distance: distance, with speed below; ranks on speed
    • Average vario
    • Average turn ✕
    • Average glide: L/D, with cruise speed below
    • Thermals ↓
    • Height gained / lost
    • Detour ↓ (info)
  • “leg to %@” (one section per leg of the longest attempt; leg rows rank on the leg’s average speed)
    • Flown declared distance: distance, with average speed below
    • Thermal % ↓
    • Avg. vario
    • Average turn ↓
    • Average glide
    • Thermals ↓
    • Detour ↓
  • “Start”
    • Start time ✕
    • Start height ✕
    • Start speed
  • “Finish”
    • Finish height ✕
    • Finish speed

Rating computation (FlightRating/*):

  • Score: 0–1000 = 10 × the weighted mean of the component scores.

    • Weights: climbing 0.30, gliding 0.25, speed 0.25, decisions 0.20.
    • Missing components are dropped and the remaining weights are renormalised.
    • A flight without a free distance or a task attempt gets no rating.
  • Bands:

    ScoreBandColour
    under 400Roughred
    under 550Fairorange
    under 700Solidyellow
    under 850Stronggreen
    otherwiseOutstandingtintSecondary #5C7EA1

    A component’s colour uses the band of score × 10.

  • Climbing (needs at least 4 thermals):

    • 60% × scale(avg climb ÷ best lift, 0.35 → 0.75)
    • plus 40% × scale(time lost in below-average climbs ÷ thermalling time, 0.30 → 0)
    • Best lift = the mean of the top quarter (at least 2) of thermals lasting at least 2 min.
  • Gliding: scale(detour ÷ line, 0.28 → 0.08). Needs a line of at least 10 km. The line is the task legs, or the free distance.

  • Speed:

    • With other flights: rank-based, 100 for the fastest down to 40 for the slowest.
    • Alone: scaled between fixed bands — glider 50–120 km/h, delta 20–60, parapente 15–45.
    • Uses the task speed if completed, else the free distance speed.
  • Decisions: start at 100, then subtract:

    • −35 for a low point under 300 m above takeoff, or −15 under 500 m
    • −20 per engine run after release
    • −10 if more than 25% of thermals were abandoned
    • −10 if more than 10% of the height gained in thermals was lost again
  • The full description strings are under flight.compare.description.rating.*.


5.6 Optimisation detail screen (Modules/App/Sources/Map/OptimizationDetail/*)

Section titled “5.6 Optimisation detail screen (Modules/App/Sources/Map/OptimizationDetail/*)”

Reached from the flight Detail popover:

  • “Performance” tab, “Distance optimization” rows: Free 5 turnpoint, Free 3 turnpoint, Triangle, Out & return, Straight. These have detail.
  • “Task” tab: “Optimized distance”, titled “Declared task”.
  • Best 1h/2h rows only highlight the track. “Maximum distance out” only centres on its fix.

Layout (SwiftUI List):

  1. A non-interactive mini map, 200 pt high, 16 pt corners, fading in once framed.
    • The optimised track is drawn dotted, in brand orange; or in success green when it is a task.
    • Task zones and legs are drawn when there is a task.
  2. Metric grid:
    • “Distance”
    • “Average speed”
    • “Start height”
    • “Start groundspeed”
    • “Finish height”
    • “Relative finish height”: ”+/−” value, green if above the minimum, orange if below.
    • “Relative duration on AAT”: ”+/−” value, green if over, orange if under.
    • “Detour”
  3. Section “Legs”:
    • An optional start row (task start waypoint name and time).
    • One row per leg:
      • Status icon: checkmark.circle.fill green, xmark.circle.fill orange, or “N.circle.fill” grey.
      • Title: the turnpoint name, or the time for free optimisations.
      • Caption: “time · speed · vario · ↻ thermal% · ⇄ detour”, or location.north with the declared bearing for an unflown leg.
      • Trailing: the leg distance in semibold.

Interactions:

  • Tap a leg: highlights the leg on the map and dismisses the popover. Unflown legs are ignored.
  • Tap the start row: centres on the start.
  • Context menu: “Go to turnpoint” (mappin, centre on the leg’s last fix) and “Go to this leg” (arrow.right).
  • Accessibility sizes stack the name above the distance.

Loading HUD (Map/Views/LoadingView.swift): a glass capsule centred in the safe area, with a spinner and caption1 text. It springs in and out (scale 0.7 ↔ 1). Messages:

  • “Loading map”: until the style loads.
  • “Loading flight”, or “Loading flights (%u / %u)”: decoding or downloading; updates per flight.
  • “Overriding task” / “Resetting tasks”
  • “Loading terrain…”
  • The same per-flight counter is shown while the sport re-analysis runs after a settings change. The map keeps its viewport and the time.

Decoding:

  • Off the main thread, concurrent at one flight per core.
  • Flights keep their listed order.
  • The first flight is selected.
  • Then the panel presents and the map frames all flights.

Errors:

  • Collected per batch: a file failed to load (message = the error description), or a duplicate flight.
  • One error: an alert titled with the filename, the message, and “Ok”.
  • Several errors: a form sheet listing each filename and message, with a Close button.
  • A dropped file without a task: “This file does not contain a task”.

Empty states:

  • An empty map (opened with no flights, the “explore” map) centres on the user’s area. There is no panel.
  • A task dropped on an empty map is drawn standalone.
  • The cloud button is disabled when no imagery is available.
  • Ranking and Compare are hidden with fewer than 2 flights.
  • Debrief, Info, Share and similar items need a selected flight.

Pro gates on this screen:

  • more than one flight
  • Override task / task drop
  • thermal detail sheet
  • SkySight clouds
  • uploads

A paywall triggered before the map is on screen is deferred until it appears.


  • Modules/App/Sources/Map/Flight/FlightMapViewController.swift: bar, menus, compare, ranking, satellite, drop, commands
  • Modules/App/Sources/Map/Flight/FlightMapViewModel.swift: loading, colours, playback, time, alignment, task override, thermals navigation
  • Modules/App/Sources/Map/Flight/FlightMapViewModel+GraphMenu.swift, FlightMapViewModel+Ranking.swift
  • Modules/App/Sources/Map/Flight/FlightMapView.swift: panel card, pills, drop overlay
  • Modules/App/Sources/Map/Flight/Panel/PanelContentViewController*.swift: control row, play menu, unit menu
  • Modules/App/Sources/Map/Flight/Panel/Graph/**: barogram
  • Modules/App/Sources/Map/Flight/Panel/Flights/**: flight cards
  • Modules/App/Sources/Map/Flight/DataSource/**, Map/Rendering/Layers/**, Map/Rendering/Views/GliderAnnotationView.swift
  • Modules/App/Sources/Map/Extensions/Flight+Polylines.swift (altitude gradient), UIColor+Theme.swift, Thermal+Feature.swift
  • Packages/Preferences/Sources/Preferences/Models/{PlaySpeed,GraphDataUnit,TrackOptimization,MapStyle}.swift; defaults in Modules/App/Sources/Application/AppDelegate.swift (prepareFlightSettings)
  • Packages/Flights/Sources/Flights/Models/{RaceProgress,RaceRanking,LeechingAnalysis*}.swift
  • Packages/FlightScreens/Sources/FlightScreens/{Compare,Hosting/Compare,RaceRanking,Hosting/RaceRanking,FlightRating,Components/CompareGridValues.swift}
  • Modules/App/Sources/Map/OptimizationDetail/*
  • Modules/App/Sources/Shell/Navigation/Sheet/SheetController.swift: the phone “card” container
  • Docs/3d-map-investigation.md §6 and §11: design rationale; 3D is not built, the ranking is

5.9 Android implementation notes for the flight map

Section titled “5.9 Android implementation notes for the flight map”
iOS elementAndroid / Compose
Full-screen Mapbox map, list chrome hiddenMapbox Maps SDK for Android v11 (MapboxMap composable from maps-compose). Same style URLs and layers; see 05-map-baro-airspace.md. Draw edge to edge (enableEdgeToEdge) with the nav bar transparent.
Floating glass bottom panel: 2 states (shown/hidden), 40 % / 45 % height, flick > 500 pt/s, midpoint snap, spring 0.5 s / damping 0.7A custom AnchoredDraggable (Modifier.anchoredDraggable with anchors Shown/Hidden), positional threshold 50 %, velocity threshold 500 dp/s, spring dampingRatio = 0.7. Don’t use BottomSheetScaffold: its peek/expanded detents and scrim semantics differ, and the panel must float 8 dp off the edges with all four corners rounded. Surface: Surface(shape = RoundedCornerShape(extraLarge), tonalElevation, color = surfaceContainer.copy(alpha≈0.92)), optionally with a blur backdrop (§9). Persist the state in DataStore. Respect ANIMATOR_DURATION_SCALE / reduce motion.
Flight chips strip (selected chip filled with the flight colour, drag to reorder, long-press “Remove from map”)LazyRow of cards (150 dp wide, radius 16). Reorder with sh.calvin.reorderable or a custom implementation. Long-press opens a DropdownMenu. On selection use HapticFeedbackType.SegmentTick / ContextClick.
Barogram (one series, bitmap renderer, handle, bubble, event pills, badges, thermal bands, terrain fill, pinch-zoom 1–10× horizontal, scroll when zoomed, ±40 pt handle grab)A custom Canvas composable. Cache the trace paths in a drawWithCache / Picture per flight, the way iOS caches bitmaps in GraphBitmapRenderer. Gestures: pointerInput with detectTapGestures, a drag detector honouring the 40 dp handle radius when zoomed, and detectTransformGestures restricted to horizontal scale. Do not use a chart library: the overlays (badges, AAT bar, event pills, flips) are bespoke. Accessibility: semantics { contentDescription; customActions }. Android has no audio graph equivalent, so expose a stepper-style progressBarRangeInfo for TalkBack.
Control row: Play ToggleButton with speed menu (10/25/50/100/200×, “Duration: m min ss sec”), “Reset zoom”, unit menu with submenusPlay becomes an M3 Expressive SplitButtonLayout: the leading button plays/stops, and the trailing chevron opens the speed DropdownMenu. This replaces the iOS “tap opens menu / long-press changes speed” idiom, which is undiscoverable on Android. Alternatively keep parity with a TextButton + DropdownMenu. Unit picker: TextButton with a trailing ArrowDropDown, opening a cascading DropdownMenu (M3 1.4 submenus / DropdownMenuGroup). Reset zoom: TextButton with AnimatedVisibility(fadeIn(150ms)).
Wind pill / phase pill / show-graph button above the panelSmall Surface(shape = CircleShape) pills offset by the panel’s animated top. Phase pill = FilledTonalButton with two lines. Show graph = SmallFloatingActionButton.
Nav bar: back, title only on wide/landscape; trailing pod [clouds, compare, debrief, ranking, info] + overflow ”…”TopAppBar with transparent container over the map, or better the M3 Expressive HorizontalFloatingToolbar at top-end for the action pod. Overflow is an IconButton(MoreVert) + DropdownMenu with groups (Go to ▸, task group, flight group, share group, Terrain, Settings). The satellite download progress ring is CircularProgressIndicator(progress) around the cloud icon, or a CircularWavyProgressIndicator.
Detail popup (sheet/popover/inspector)Compact: ModalBottomSheet (or non-modal BottomSheetScaffold with partial expansion, matching the planned iOS step 6). Expanded: SupportingPaneScaffold supporting pane 270–400 dp. Content is a nested NavHost (summary → page → optimisation detail). See §6.13.
Companion cards (Debrief, Ranking, Thermal): phone card 40–60 % height drag-to-dismiss; iPad popover with map passthroughPhone: a custom draggable card (same AnchoredDraggable as the panel) without a scrim, so the map stays interactive. Expanded: the supporting pane. Only one card at a time, so model it as a sealed Companion state in the map ViewModel.
Loading HUD capsule “Loading map” / “Loading flights (n / m)”M3 Expressive LoadingIndicator (morphing shapes) inside a Surface(CircleShape) capsule with text. Enter/exit with scaleIn(0.7f) + fadeIn and a spring.
Drop overlay (Add / Replace / Use task targets)Modifier.dragAndDropTarget on the map. Two Cards that highlight on onEntered/onMoved by y position.
Keyboard shortcuts (space, ←/→, ⌥←/→, ⌘←/→, ⌥⌘S/R/F/G)Modifier.onPreviewKeyEvent on the map screen. Publish a KeyboardShortcutGroup via onProvideKeyboardShortcuts.
Playback engine on CADisplayLinkwithFrameNanos loop in a LaunchedEffect. Accumulate elapsed × multiplier and step in whole seconds. Pause on Lifecycle.Event.ON_PAUSE.
Compare screen (UIKit collection grid with sticky label column and header, pin baseline, podium pills, info popovers)A custom 2-D grid: LazyColumn of rows sharing one rememberScrollState() for horizontal scroll, the label column frozen, the header as a stickyHeader. Columns: 2 on compact, min(6, n) on expanded. Info popover becomes a TooltipBox with RichTooltip (M3), triggered on click. Podium pill = a Surface with a tint at 8 % alpha + a number badge. Filter = IconButton + multi-select DropdownMenu with checkmarks that stays open. Pinning = FilterChip-like header capsules (InputChip with a pin icon).
Race ranking listLazyColumn with Modifier.animateItem() to animate reorders. A 32 dp circle badge in the flight colour. Finishers get a checkered-flag icon.
Optimisation detail mini mapA MapboxMap with gestures disabled (or a Snapshotter bitmap), 200 dp tall, RoundedCornerShape(16.dp).
Pro gates (several flights, override task, thermal sheet, SkySight, uploads)One ProGate.require(feature) { … } helper that routes to the paywall destination. Defer it until the map is resumed, as iOS does.

6. Flight detail panels, thermal detail, edit, export/upload, Debrief, rating, photos

Section titled “6. Flight detail panels, thermal detail, edit, export/upload, Debrief, rating, photos”

Note: inside §6, references such as “§2.4” or “§5.8” point to this section’s own subsections (§6.2.4, §6.5.8, …).

  • Read-only. Nothing in /Users/fousa/Projects/vulture-ios was modified. All paths below are relative to it.
  • String catalogs.
    • [DS] = Packages/DesignSystem/Sources/DesignSystem/Resources/Localizable.xcstrings
    • [App] = Modules/App/Resources/Localizable.xcstrings. Several flight-detail keys live only here.
    • Unit symbols come from Packages/Preferences/.../Localizable.xcstrings.
  • Package moves. The design docs say Packages/Scenes/... and Modules/App/Sources/Modules/Detail/. That code now lives in Packages/FlightScreens/Sources/FlightScreens/ and Modules/App/Sources/Features/Flight/Detail/.

The formatters live in Packages/Preferences/.../Units/Formatter/UnitFormatter.swift and its extensions.

Output is "<value> <unit>" in the user’s unit setting.

Decimals:

  • No decimals: km/h, kt, mph, m, ft, °C. The format string is %0.00f, which gives 0 decimals.
  • 2 decimals: m/s, km, mi, nm.
  • ft/s uses a raw %f (6 decimals). This is a bug; do not copy it.

Unit strings: km/h, kt, mph, m/s, ft/s, m, ft, km, mi, nm, L, %.

ValueFormatExample
Durations (timeValue)HH:MM:SS (%.2d:%.2d:%.2d). Negative values or values over 24 h wrap around one day.04:32:11
veryShortTimeValueHours, minutes and seconds, with zero parts dropped4m 12s, 1h 3m
percentageValue%0.2f %%41.23 %
degreesString / bearingString%i°25°
Glide ration:142:1
Water (shortWaterValue)"<n> liter", hard-coded English120 liter
Count (stringValue on Double)%0.2f—
mediumDayStringDateFormatter medium date25 Jun 2025
fullDayString.full date styleWednesday 25 June 2025
  • A DetailInfoItem returns nil when its value is nil or empty.
  • A two-column row whose two items are both nil is dropped.
  • A section with no items is dropped.

This is the main conditional-visibility mechanism on every page.


6.1 Detail popup: structure and presentation

Section titled “6.1 Detail popup: structure and presentation”

The entry point is the flight map’s info bar button.

PlatformGlyph
iPhone / iPadinfo
Macinfo.circle, and info.circle.fill while the column is up
  • Accessibility label: accessibility.button.menu.detail = “Open detail” [App].
  • Menu command: showInfo, shortcut ⌘I.
    • On the Mac its title is “Show Detail” / “Hide Detail” (menu.button.showdetail / menu.button.hidedetail).
    • Elsewhere it is “Open detail”.
  • Code: FlightMapViewController.toggleInfo → MapInspector.toggle(.detail) (Modules/App/Sources/Map/Extensions/MapInspector.swift).
  • The content is rebuilt fresh on every open: detailNavigationController() creates a new DetailPopupController(viewModel: DetailPopupViewModel(viewModel: selectedFlightViewModel, preferences: .shared)).
PlatformContainerNotes
iPhone (compact)A UINavigationController with .popover style. UIKit adapts it to a modal page sheet (MapPopoverPresentationDelegate, rule .automatic at compact width).A Close button (system .close, accessibility “Close” accessibility.button.close) is added as the left bar item. There are no custom detents. The planned non-modal medium/large-detent sheet (“step 6” in Docs/flight-detail-summary-redesign.md) is not built. Tapping a phase, turnpoint, leg, infringement or “Maximum distance out” dismisses the sheet, then the map centres or highlights.
iPad (regular)A popover anchored on the info button.preferredContentSize = 400 × 2000 (as tall as the screen allows). Backgrounds are cleared so the popover glass shows through (view.backgroundColor = .clear and .scrollContentBackground(.hidden) at regular width). The same tap actions dismiss the popover.
Mac (Catalyst)A trailing inspector column of the split view (UISplitViewController .inspector, 270 pt; Numbers’ range is 250–480) holding InspectorNavigationController(rootViewController: DetailInspectorController).There is no summary page; there are icon tabs instead (§1.5). The column stays up while the map is used. Taps do not dismiss it, because presentingViewController is nil. The map opens with the column closed. The column goes away when the map is popped or replaced.

Android mapping:

  • Phone: a ModalBottomSheet. Consider the planned non-modal partially-expanded sheet so the map stays usable.
  • Tablet: a side panel (inspector), or a popover-like anchored panel.

Code:

  • Packages/FlightScreens/.../FlightDetail/Summary/DetailSummaryView.swift
  • DetailSummaryViewModel.swift
  • DetailSummaryRow.swift
  • DetailSummaryRowView.swift
  • Controller: Modules/App/Sources/Features/Flight/Detail/DetailPopupController.swift

Navigation bar

  • Title: flight.date.mediumDayString, e.g. “25 Jun 2025”.
  • Subtitle: glider type and competition ID joined with ” · ”, e.g. “Ventus 3T · EY”.
    • Each value comes from the logbook record, falling back to the IGC header when the record field is blank.
    • The subtitle is hidden when both are empty.
  • Right bar item: one button, exclamationmark.triangle, titled flight.summary.button.report = “Report incorrect calculation” [App].
    • It is a plain button, not a menu.
    • It opens the report mail (§5.8).
  • Left bar item: Close, on iPhone only.

List (inset-grouped; plain on the Mac)

1. Hero (no card background, insets 4/20/8/20)

  • Track thumbnail (only when available):
    • A rounded rect, radius 16, filled with tertiarySystemFill, 168 pt tall.
    • The track is drawn in the brand colour, 2.4 pt line, 16 pt inset, with a start dot. The aspect ratio is not clamped.
    • Source: the cached CoreFlight.trackPreview, otherwise TrackSimplifier run on the fixes.
  • heroTitle (title2 semibold, max 2 lines). The first non-empty of:
    1. the declared task name
    2. the record’s takeoff location
    3. the glider type
    4. the medium date
  • heroSubtitle (footnote, secondary, monospaced digits): the full date, followed by " · HH:MM:SS – HH:MM:SS" (takeoff fix time – landing fix time) when both fixes exist.

2. Metric grid (DetailMetricGrid)

  • Two columns separated by hairline dividers. It collapses to one column at accessibility text sizes.
  • Each tile: the label (footnote, secondary, 1 line) over the value (title3 semibold, monospaced; headline on the Mac) with the unit split off (footnote, secondary).
  • The unit is split from the formatted string at the last whitespace, but only when the trailing part has no digits.
  • Tiles with no value are dropped. A lone tile keeps half width.
TileKey → EnglishValue
Distanceflight.statistics.data.optimized.distance → “Distance”judgedDistance: best task attempt distance if > 0, otherwise free 5-turnpoint distance if > 0
Durationflight.statistics.data.duration → “Duration”judgedDuration = duration (takeoff→landing) ?? glidingBlockDuration, as HH:MM:SS
Average speedflight.info.label.cruise.speed → “Average speed”judgedAverageSpeed: task attempt speed when the task has a distance, otherwise free-distance speed; must be > 0
Height gainflight.info.label.altitudegained → “Height gain”highest − lowest pressure altitude, shown only when > 0

These are the same four tiles as the list’s long-press preview (SidebarListPreview).

3. “Analysis” section (flight.summary.header.analysis = “Analysis”)

  • Row anatomy:
    • A 32 pt circle badge filled with the tint colour at 15% opacity, holding the SF symbol in footnote semibold in the tint colour.
    • Title (body, 1 line), with the caption below it (caption, secondary, monospaced, 1 line).
    • A trailing headline (semibold, monospaced, in the tint colour, or primary when the tint is neutral), with headlineDetail below it (caption, secondary).
    • A chevron.
    • At accessibility sizes the figures wrap below the title.
    • The accessibility label is title, caption, headline and detail joined with ”, ”.
  • Order is fixed. A row is omitted when its page would be empty.
#PageTitle (key)CaptionHeadline / detailIconTintShown when
1declaredTask“Declared task” (flight.task.info.header)“%u turnpoints” (flight.summary.caption.turnpoints, plural: “1 turnpoint”)“Finished” (flight.label.optimization.task.completed) or “Unfinished” (…task.unfinished) / declared task distance if > 0point.topleft.down.to.point.bottomright.curvepathsuccess green if completed, otherwise accentflight.task and flight.bestTaskAttempt exist
2performance“Performance” (flight.tab.label.performance)name of the furthest optimisation (e.g. “Free 5 turnpoint distance”)its distance / its average speed if > 0rosetteaccent (brand orange)any optimisation with distance > 0. Candidates: Free 5 TP, Free 3 TP, Triangle, Out & return, Straight
3thermals“Thermals” (flight.tab.label.thermals)“%u thermals” (flight.summary.caption.thermals)average thermal vario / thermal percentagetornadothermal green (asset “Thermal”)thermal count > 0
4phases“Phases” (flight.tab.label.phases)“%u cruises” (flight.summary.caption.cruises)total phase countlist.bulletsecondary blue (#587899 light / #5C7EA1 dark)phases not empty
5flight“Flight” (flight.tab.label.flight)flightDuration (HH:MM:SS)—airplaneneutral greyalways
6infringements“Airspace” (airspace.label.title)“%u Infringements” (flight.label.airspace.crossing; plural “1 Infringement”, zero “No infringements”)—exclamationmark.triangle.fillwarning red (infringement)live airspace scan reports more than 0 crossings
7info“Info” (flight.tab.label.info)pilot name (record, otherwise IGC)—info.circleneutralalways
  • Row 6 updates live. It appears once the asynchronous airspace scan publishes. The scan keeps running while the map pans.
  • What each row opens:
    • declaredTask → the Optimization detail page (§2.6) for flight.bestTaskAttempt, titled “Declared task”.
    • infringements → the Airspace infringements list (§2.7).
    • Every other row → its tab page (§2).
  • Navigation stack: each page is a DetailTabPageController pushed onto the popup’s stack. Each page keeps its own bridge, built once up front, so its scroll position survives push and pop.

4. “Remark” section (flight.info.label.remarks = “Remark”)

  • Shown only when the record’s comments is not blank.
  • Contains the full comment text in body style.

Refresh: viewWillAppear calls invalidateContent(). The record publisher (KVO on waterBalast, plus deletion) and the airspace publisher also trigger rebuilds. Deletion keeps the last snapshot on screen.

  • Title: the page title.
  • Right bar items: the page’s barItems. Only the Task page has any.
  • Phases page: the navigation title is a title menu (titleMenuProvider) that picks the phase filter (§2.4). The title shows the current filter name.

6.1.5 Mac inspector (DetailInspectorController.swift)

Section titled “6.1.5 Mac inspector (DetailInspectorController.swift)”

Layout, top to bottom:

  1. A round glass report button (exclamationmark.triangle, 36 pt) at top-left, 8 pt from the top.
  2. A header row 40 pt tall:
    • The flight name (WindowTitle.title, headline) and subtitle (footnote, secondary).
    • Trailing: the shown page’s accessories. For Phases this is a filter button (line.horizontal.3.decrease) whose menu is built when it opens.
  3. A UISegmentedControl of icons only, one segment per summary row in the same order, with the selected segment tinted brand and its symbol white.
    • Segment accessibility names: “Task” (flight.tab.label.task), “Performance”, “Thermals”, “Phases”, “Flight”, “Airspace”, “Info”.
  4. The selected page, embedded as a child controller.

Behaviour:

  • Initial page: the last page shown (Defaults[.inspectorPage], stored as the raw page name) if this flight has it, otherwise the first row.
  • Tab rebuilds: the tabs rebuild when the airspace result changes the set of rows, keeping the current selection.
  • Flat dress (DetailListAppearance.isMac):
    • Plain list on a clear background, with no section headers (DetailSectionTitle draws nothing).
    • Metric values in headline.
    • Phase badges 26 pt instead of 32.
    • Optimisation map 160 pt tall with radius 8, instead of 200 pt with radius 16.

Code: Packages/FlightScreens/.../Tabs/DetailTabConvertible.swift.

Actions the map acts on (FlightMapViewController.subscribe(controller:)):

ActionMap behaviour
.centerFix(fix)viewModel.goTo(fix, true): moves the replay glider to that fix and centres the map
.highlightFixes([Fix])Highlights the track segment
.highlightInfringement(inf)Highlights the infringement’s fix segments

Actions handled inside the popup (DetailPopupActionPresenter.swift):

  • .share(urls): share sheet anchored to the first right bar item.
  • .presentRenameWaypointAlert
  • .presentAddWaypoint / .presentInsertWaypoint: pushes the waypoint picker.
  • .dismiss
  • .reportFlight: report mail.

Only the presenter of the top-most screen acts on an action.

Map side: the flight map’s overflow menu gains “Center leg” / “Clear leg” (flight.button.centerleg, flight.button.clearleg) and “Center infringements” / “Clear infringements” while a highlight is active.


Common page layout (DetailTabContentView.swift):

  • A List with one Section per DetailContentSection, with a header (DetailSection.headerTitle) and an optional footer.

Row types (DetailItemRow.swift):

TypeLayoutTappable?
InformationOne or two columns separated by a vertical divider. Each column is a title (body) over the value (body semibold, monospaced).Only if it is a single item with a handler
PhaseSee §2.4Yes
OptimizationTitle (caption, secondary); distance (body) + speedometer + speed (footnote); optional arrow.triangle.swap + detour (footnote, secondary)Yes. Has a chevron if isDetailAvailable.
GraphSee belowNo
Metrics gridSame as §1.3No
TurnpointSee §2.5Yes

Graph rows (DetailGraphRow + FlightDetailGraphView, Core Graphics):

  • A bar chart 100 pt tall.
  • When value labels are on, a left legend shows max, max/2 and 0.
  • Below the bars: caption titles, one per bar.
  • Vertical padding 12.

6.2.1 Flight page (DetailTabFlightViewModel.swift)

Section titled “6.2.1 Flight page (DetailTabFlightViewModel.swift)”

Title: “Flight” (flight.tab.label.flight).

Section headerRow (left | right)Value / format
“Flight” (flight.info.header.flight)“Total flight duration” (flight.info.label.time.duration) | “Total gliding duration” (flight.info.label.flight.duration)duration (takeoff→landing) | flightDuration = landing − (release ?? takeoff) − engine time; both HH:MM:SS
“Takeoff type” (flight.info.label.takeofftype) | “Takeoff performance” (flight.info.label.takeoff.performance)“Self launch” / “Winch” / “Towing” (flight.info.label.takeoff.selflaunch / .winch / .tow; hidden if unknown) | average climb rate from takeoff to release (pressure altitude, falling back to GPS altitude), vertical-speed unit
“Average wind speed” (flight.info.label.averagewind) | “Water ballast” (flight.info.label.water)mean of wind estimates (wind unit; shows 0 when there are no estimates) | record waterBalast as “N liter” (hidden without a logbook record)
“Cruising” (flight.info.header.cruise)“Total duration” (flight.info.label.cruise.duration) | “Average speed” (flight.info.label.cruise.speed)Σ leg duration | Σ leg distance / Σ leg time
“Average glide ratio” (flight.info.label.cruise.glideratio)−Σ distance / Σ altitude difference as an integer, but rendered through stringValue, giving e.g. “42.00”. The rendering is odd; consider “42:1”.
“Average flap usage” (flight.info.header.flapsgraph)GraphOnly if flight.flaps.count > 1. One bar per flap position, counting fixes at that position. Titles are the flap names. No value labels. Bars in secondary tint.
“Engine” (flight.info.header.engine)“Engine registration” (flight.info.label.engine.type) | “Total engine usage” (flight.info.label.engine.count)“ENL”, “MOP” or “ENL + MOP” | number of engine runs
“Total engine time” (flight.info.label.engine.duration)HH:MM:SS
“Information” (flight.info.header.info)“Date” (flight.info.label.date)full day string
“Pilot” (flight.info.label.pilot)record ?? IGC
“Passenger” (flight.info.label.passenger)record ?? IGC
“Remark” (flight.info.label.remarks)record comments

Notes:

  • The whole Engine section appears only when the IGC has an ENL or MOP extension.
  • There are no tap actions on this page.
  • It reloads when the record changes (for example, water ballast edited) and when airspace results arrive.

6.2.2 Performance page (DetailTabPerformanceViewModel.swift)

Section titled “6.2.2 Performance page (DetailTabPerformanceViewModel.swift)”

Title: “Performance”.

Section “Achievements” (flight.info.header.achievements):

RowValueAction
“Free flight duration” (flight.info.label.glide.duration) | “Height gain” (flight.info.label.altitudegained)gliding-block duration (engine-free block) | altitude gained—
“Best 1h distance” (flight.label.optimization.1hspeed)Optimization row (distance, speed)Tap highlights the optimisation’s fixes on the map and dismisses the popup. No chevron (isDetailAvailable = false).
“Best 2h distance” (flight.label.optimization.2hspeed)samesame
“Maximum distance out” (flight.label.optimization.maxout)distanceTap → .centerFix(last fix) and dismisses the popup

Section “Optimization” (flight.info.header.optimization):

  • Footer: flight.label.optimization.footer = “The distance calculations in Vulture are provided by Koen Peeters 🙏”.
  • Each row is an Optimization row with a chevron. Tapping pushes the Optimization detail page (§2.6).
OrderRowKey
1“Free 5 turnpoint distance”flight.label.optimization.free
2“Free 3 turnpoint distance”flight.label.optimization.three
3“Triangle distance”flight.label.optimization.triangle
4“Out & return distance”flight.label.optimization.outandreturn
5“Straight distance”flight.label.optimization.out

Rows whose optimisation is nil are dropped. The declared task is no longer on this page; it is the summary’s first row.

6.2.3 Thermals page (DetailTabThermalsViewModel.swift)

Section titled “6.2.3 Thermals page (DetailTabThermalsViewModel.swift)”

Title: “Thermals”. This page has no thermal list and no tap actions.

Section “Thermals” (flight.info.header.thermals):

LeftRight
“Number of thermals” (.thermal.total): count, including attempts“Total duration” (.thermal.duration): Σ thermal duration
“Average vario” (.thermal.average): Σ gain / Σ duration“During flight” (.thermal.percentage): thermal time / flight duration, as %
“Best vario” (.thermal.best): max thermal vario“Distance between thermals” (.thermal.distance): mean distanceToNextThermal
“Height gained / lost” (.thermal.height): “Σ gained / Σ lost” in the altitude unit“Average drift” (.thermal.drift): mean drift, distance unit
“Lowest entry altitude” (.thermal.lowestentry): lowestAltitudeFix.pressureAltitude“Average diameter” (.thermal.diameter): mean radius × 2, altitude unit (m or ft)

All the keys above are prefixed flight.info.label..

Section “Performance” (flight.info.header.thermalperformance):

LeftRight
“Left turns” (.thermal.left): % of thermals“Right turns” (.thermal.right): %
“Average vario”: left-turn mean vario“Average vario”: right-turn mean vario
“Mixed turns” (.thermal.mixed): %“Failed attempts” (.thermal.failed): thermalAttemptCount
“Average turn rate” (.thermal.turnrate): “%i°”“Average airspeed” (.thermal.airspeed): only if the IGC has a TAS extension

The left/right “Average vario” row reuses the same label for both columns. The column position is the only cue, so consider “Average vario (left)” and “(right)”.

Section “Number of thermals / vario” (flight.info.header.thermalgraph):

  • Footer: flight.label.thermals.footer = “The thermalling calculations in Vulture are provided by Koen Peeters 🙏”.
  • A bar graph with value labels: a histogram of thermal varios (m/s) in 7 buckets.
Bucket (m/s)Label (m/s user)Label (kt user)Colour
< 0“<0”“<0”Thermal sink (#F61D24)
0–1“0-1”“0-2”ramp 0.0
1–2“1-2”“2-3”ramp 0.2
2–3“2-3”“3-6”ramp 0.4
3–4“3-4”“6-8”ramp 0.6
4–5“4-5”“8-10”ramp 0.8
≥ 5“5+”“10+”ramp 1.0
  • The ramp blends Bad (#F6B91E) → Average (#F6EE1E) → Good (#98F61E).
  • With knots selected only the labels change; the bucket edges stay in m/s.

6.2.4 Phases page (DetailTabPhasesViewModel.swift)

Section titled “6.2.4 Phases page (DetailTabPhasesViewModel.swift)”

Title: the current filter name, shown as a title menu. On the Mac it is a filter button instead.

Single section:

  • Header flight.info.header.events = “Select a phase to show it”.
  • Footer flight.label.event.generated = “Events marked with a dot are calculated and can deviate from the real value.”

Content: flight.phases, which is thermals + cruise legs + engine runs + event fixes, sorted chronologically.

Filter menu (PhaseSelection). The choice is persisted globally in preferences (settings.phasesSelection) and the current one is checked.

OrderOptionKeyIconShows
1“All phases”selection.button.phaseslist.dashall
2“Events”selection.button.eventsmallcircle.fill.circleevent fixes only
3“Engine usage”selection.button.enginebolt.fillengine runs
4“Thermalling”selection.button.thermaltornadothermals
5“Cruising”selection.button.cruisearrow.rightcruise legs

Scroll position is remembered per flight id (settings.remember(offset:for:)).

Phase row anatomy (DetailPhaseRow):

  • A 32 pt tinted badge. It carries an “unread” dot (9 pt, asset “Unread”) when the event was computed rather than recorded.
  • Title, with a caption line “time · caption” (time is HH:MM:SS).
  • A trailing value (semibold, tinted) with a detail line below it.
KindTitleCaptionValue / detailIconTint
Eventevent name (table below)takeoff/landing: “alt MSL”; others: “alt − elevation AGL”—per eventgrey
Thermal“Thermal” (flight.label.phase.thermal)duration (veryShortTimeValue, e.g. “4m 12s”)vario / “+gain” (with a ”+” when positive). A negative vario is muted grey.left arrow.counterclockwise, right arrow.clockwise, mixed circle, unknown tornadothermal green
Cruise“Cruise” (flight.label.phase.cruise)average speeddistance / “n:1” glide ratioarrow.forwardsecondary blue
Engine“Engine run” (flight.label.event.enginerun)duration HH:MM:SS—bolt.fillgrey

Event icons:

EventIcon
takeoffplay
releasescribble
landingflag
low pointarrow.down.to.line
high pointarrow.up.to.line
furthest pointbinoculars
anything elsesmallcircle.fill.circle

Event names (Extensions/Event+Name.swift, keys flight.label.event.*):

  • Pilot event: “PEV”
  • “Altimeter pressure setting”
  • “Blind instrument flying on” / “off” / “Blind instrument flying”
  • “Change of geodetic datum”
  • “Finish”
  • “Flap position”
  • “Groundspeed”
  • “McReady”
  • “Low voltage”
  • “On task”
  • “Start of pre-declared task”
  • “Turnpoint confirmation”
  • “True track”
  • “Undercarriage”
  • “Takeoff”
  • “Release”
  • “Landing”
  • “Lowest point of height gain”
  • “Highest point of height gain”
  • “Furthest point from home”

Tap actions (each also dismisses the popup on iPhone and iPad):

PhaseAction
Event.centerFix(fix)
Thermal.centerFix(first fix)
Engine.centerFix(start fix)
Cruise.highlightFixes(leg fixes)

Accessibility labels: thermal ”%@ Thermal”; cruise ”%@ Leg”.

Thermal detail does not open from this list. It opens from the map (§3).

6.2.5 Task page (task popup root; also the declared task when shown as a page)

Section titled “6.2.5 Task page (task popup root; also the declared task when shown as a page)”

Code: DetailTabTaskViewModel.swift.

  • Used by: TaskMapViewController as the task popup’s single root page. There is no summary for tasks.
  • Title: “Task” (flight.tab.label.task).

Section “Declared task” (flight.task.info.header):

  • Metrics grid:
    • “Declared distance” (flight.turnpoints.info.label.distance)
    • “Minimum finish altitude” (…minimumheight): the last waypoint zone’s minimum altitude
    • “Minumum AAT duration” (…minimumtime, typo in the source)
  • Only when a flight is attached:
    • Info row “Status” (flight.label.optimization.task.status), coloured:
      • checkmark.circle.fill + “Finished” in green
      • xmark.circle.fill + “Unfinished” in brand
      • “Not started” in label colour (…task.notstarted)
    • Optimization row “Optimized distance” (flight.label.optimization.task), with a chevron, which pushes the Optimization detail page.

Section “Turnpoints” (flight.turnpoints.info.header):

  • One row per waypoint:
    • Type caption: “Start” / “Turnpoint” / “Finish” (task.label.start / .point / .finish).
    • Name (body).
    • Trailing callout, secondary: “bearing / distance” from the previous waypoint.
    • Tertiary caption lines: elevation, coordinate, zone description.
  • Not editable: tapping sends .centerTurnpoint(offset) and dismisses the popup.
  • Editable: has a chevron and pushes the Edit Waypoint screen. A last row “Add turnpoint” (task.button.turnpoint.add, plus.square, accent, centred) pushes the waypoint picker.
  • Swipe action (editable only): “Delete turnpoint” (task.edit.button.delete, trash, destructive).
  • Context menu (editable only; the preview card is WaypointPreviewView). Three groups:
    1. “Go to turnpoint” (task.menu.center.turnpoint, mappin.and.ellipse). Centres the map and dismisses the popup.
    2. “Insert turnpoint” with subtitle “Before current turnpoint” (text.insert), and “Insert turnpoint” with subtitle “After current turnpoint” (text.append).
    3. “Rename” (task.button.rename, pencil). Opens an alert:
      • Title: “Rename waypoint”.
      • Message: “You can give a custom name to this waypoint, or leave the field empty to use a original waypoint name.”

Further sections:

  • “Inactive airspace” (flight.task.inactiveairspace.header): free text.
  • “Task notes” (flight.task.notes.header): free text.

Bar items:

  • Share menu (square.and.arrow.up, accessibility “Share task”), in this order:

    1. “Save task” (task.edit.share.button.export.save, square.and.arrow.down). Only when a save callback exists.
    2. “Export to CUP” (doc)
    3. “Export to LXNAV Nano”
    4. “Export to FLARM” (dot.radiowaves.right)

    Each export writes the file to the temp directory and opens the share sheet.

  • Reverse task (arrow.up.arrow.down, accessibility “Reverse task”). Shown when editable and the task has more than one waypoint.

6.2.6 Optimization detail page (declared task / any distance optimisation)

Section titled “6.2.6 Optimization detail page (declared task / any distance optimisation)”

Code: Modules/App/Sources/Map/OptimizationDetail/OptimizationDetail{View,ViewModel}.swift, Packages/FlightScreens/.../Models/DetailOptimizationItem(+Legs).swift.

Title:

  • Without a task: the optimisation name.
  • With a task: “Declared task”.

1. Hero map (Mapbox, 200 pt tall, radius 16; 160 pt / radius 8 on the Mac)

  • The optimised track polyline.
  • With a task, the task features are drawn too.

2. Metrics grid, only the tiles that have a value:

OrderLabel (key)Value
1“Distance” (flight.label.optimization.distance)distance
2“Average speed” (flight.label.optimization.speed)speed
3“Start height” (flight.turnpoints.info.label.startheight)
4“Start groundspeed” (…startspeed)
5“Finish height” (…finishheight)
6“Relative finish height” (…relativefinishheight)±altitude; green when ≥ 0, brand when below
7“Relative duration on AAT” (…aatduration)±HH:MM:SS; green when over, brand when under
8“Detour” (…detour)Σ leg detours, when > 0

3. Section “Legs” (flight.label.optimization.legs)

  • Start row (only with a task): the start waypoint name + start time. Tapping centres the map on the start fix.

  • One row per leg (OptimizationLegRow):

    PartContent
    Iconcheckmark.circle.fill green = leg reached; xmark.circle.fill brand = not reached; N.circle.fill grey = no task
    Titlewaypoint name, or the time
    Caption (parts joined with ” · ”)time, average speed, thermal average vario, arrow.clockwise + thermal %, arrow.triangle.swap + detour, location.north + declared bearing (unflown legs only)
    Trailingdistance, semibold
  • Tap a leg: highlights the leg’s fixes and dismisses the popup.

  • Context menu on a leg: “Go to turnpoint” (flight.label.optimization.point, mappin) and “Go to this leg” (flight.label.optimization.highlight, arrow.right).

Leg calculation:

  • Per leg: straight distance between consecutive optimisation points, average speed, and thermals contained in the leg (count, attempts, average vario, turn rate, diameter, % thermalling).
  • Cruise speed and glide ratio from the flight legs in range.
  • Detour = flown distance (each thermal collapsed to an entry→exit chord) − straight distance. Only reported when > 100 m.
  • Task legs never reached are appended as “unflown” (status fail, distance and bearing from the waypoints).

Code: Packages/FlightScreens/.../AirspaceInfringements/*, plus AirspaceInfringementsHostingController.

  • Title: “%u Infringements”. It updates live as the map scan finds more.
  • Each row:
    • Airspace class indicator swatch (20 pt).
    • Class string.
    • Trailing clock + total duration inside (veryShortTimeValue).
    • Airspace name (caption, secondary) below.
  • Footer (only when openAIP airspace is in use): “Infringements are shown for airspace visible on the map. Pan or zoom the map to check additional areas.” (flight.label.airspace.openaip.footer).
  • Tap: .highlightInfringement and dismisses the popup.
  • Legacy delayed row: “Airspace usage”, with “Processing…” while the scan runs (flight.label.airspace.title / .progress). It still exists in code but no page uses it now.

6.2.8 Info page (DetailTabInfoViewModel.swift)

Section titled “6.2.8 Info page (DetailTabInfoViewModel.swift)”

Title: “Info”.

SectionRows
“Takeoff” (flight.info.header.takeoff)“ICAO” (record takeoffCode), “Location” (record takeoffLocation)
“Glider” (flight.info.header.glider)“Type”; “Registration” | “Competition number”; “Competion class” (typo in the source: flight.info.label.competition.class). Each value is record ?? IGC.
“Equipment” (flight.info.header.equipment)“Logger serial number”, “Logger type”, “Flarm version”, “Flarm serial number”, “GPS manufacturer”, “Pressure altitude sensor”, “MOP sensor” (from the IGC header)
“Remark”comments, with no title

There are no tap actions on this page.

No UI row is conditional on sport. Sport affects the analysis thresholds instead (SportProfile: glider, delta, parapente). Sport is auto-detected from 95th-percentile groundspeed (> 100 km/h glider, 80–100 delta, < 80 parapente) unless the “Detect Sport Automatically” setting is off; see Docs/sport-specific-analysis.md.

Visibility therefore follows the data:

ElementAppears when
Winch / tow / release / takeoff performancerelease detected (glider)
Engine sectionENL or MOP present
Flaps graphthe flaps extension is present
AirspeedTAS present
Water ballastlogbook record

The only sport-dependent display is the Speed component of the flight rating (§7).


Code: Packages/FlightScreens/.../ThermalDetail/*, Modules/App/Sources/Features/Flight/Thermal/ThermalDetailViewController.swift, Map/Flight/Thermal/ThermalController.swift.

Entry points:

  • Tap a thermal circle on the flight map. Photo hit-testing goes first.
  • “Go to nearest thermal”.
  • Menu-bar commands “browse next/previous thermal”.

Pro gating: Pro-gated by ProAccess.allows(.thermalAnalysis), which shows a paywall. The thermals drawn on the map stay free.

Presentation:

  • A draggable bottom card over the map (SheetController). If it is already open, it is reconfigured in place.
  • It closes the Coach/Debrief card.

Paging set, from the “show thermals” / “show attempts” preferences:

show thermalsshow attemptsPaged set
ononall thermals
onoffreal thermals only
offonattempts only
offoffnil

Map hooks:

  • Selecting a thermal centres the map on it.
  • Tapping the title centres it.
  • Scrubbing a chart moves the glider without moving the camera.
  • Title: a tappable viewfinder + “Thermal %i / %i” (thermal.label.title [App]).
  • Left: Close.
  • Right: previous (arrow.left) and next (arrow.right).
    • Accessibility: “Select the next thermal” / “Select the previous thermal”.
    • Each is disabled at the end of the list.
  • The sheet only drags on downward velocity, and never inside the chart’s scrub area.
  • Segmented control:
    • “Climb” (thermal.chart.climb, the default)
    • “Centering” (thermal.chart.centering)
    • “3D” (thermal.chart.helix)
  • Compact: a scrolling column with the picker, the chart at 220 pt and the data grid at 280 pt. The scroll position resets for each thermal.
  • Regular: chart on the left, grid on the right. Minimum sheet height 372 pt; minimum chart height 80 pt.

A glass card, radius 12, 4 rows × 2 columns, with hairline separators. Each cell is a value (body) over a caption title.

RowLeftRight
1vario, in headline / “Average vario” (thermal.label.average)turn symbol (arrow.counterclockwise / arrow.clockwise / circle) / “Left turn” · “Right turn” · “Mixed turn”. Accessibility “Direction”.
2radius × 2 in the altitude unit / “Diameter”“%i°” / “Turn rate”
3average airspeed / “Average airspeed”“gained / lost” / “Height gained / lost”
4HH:MM:SS / “Duration”drift / “Drift”
RateColour
< 0Sink #F61D24
0Bad #F6B91E
2Average #F6EE1E
≥ 4Good #98F61E

Rates between stops use a linear RGB blend. The colours are the same in light and dark mode.

The charts use every flight fix from the thermal’s first fix to its last, not just thermal.fixes.

Two stacked plots. Each has a tappable dotted-underline header that opens an info popover.

HeaderPopover text
“Altitude”“Your height through the thermal, from entry to the top. The fill shows how strongly you were climbing at each moment. Drag across to read the height and time at any point.”
“Climb rate”“How fast you were climbing at each moment. The band covers the range within that moment, so a short surge stays visible, and the line through it is the average.”

Columns:

  • Column width is 8 pt, so columnCount = plotWidth / 8.
  • The fixes are split into equal slices; each column’s height is the altitude at the end of its slice.
  • Fill: ramp(mean rate) at 0.8 opacity, corner radius ≤ 4.

Rate strip:

  • Height = clamp(0.26 × height, 26, 140).
  • A zero line, then bars to the max rate (0.35 opacity) and to the mean rate (0.8 opacity).

Labels:

  • Altitude axis: max, mid and min.
  • Time ticks every 60 s, formatted “m:ss”.

Scrub:

  • A drag with minimum distance 0 snaps to the nearest sample and moves the map glider.
  • It draws a vertical line, 9 pt ring markers, and a pill reading “rate altitude m:ss”.
  • Accessibility: label “Climb profile”, with adjustable actions.

Caption: “Last %1$@ at %2$@ · faded before you left / still climbing when you left”.

  • The trailing window is clamp(duration/4, 30, 90) s, rounded to the nearest 15 s.
  • The verdict is “late” when the trailing average is below 0.7 × the overall average (and the overall average is > 0).

Geometry:

  1. Project to a flat plane.
  2. Fit wind as a least-squares linear drift against time.
  3. Subtract the drift so the circles stack.

Derived values:

  • radius = mean distance from the centre.
  • core = rate²-weighted centroid of points with climb > 0.
  • centering = max(0, 1 − coreDistance / radius).

Drawing:

  • Catmull-Rom track segments coloured with the ramp.
  • A faint flown-circle disc.
  • A centre cross.
  • A dashed line to a core marker.

Controls:

  • Info button “Centering” with this long text: “Your circles seen from above, with the wind taken out so they stack on top of each other. The dashed ring is the circle you flew and the cross its middle; the marker is where the lift actually was. The gap between them is how far off the core you were circling. Pinch to zoom in and drag to look around; double-tap to see it all again.”
  • Toggle “Wind correction”, default on, tinted secondary. It animates between drifted and stacked over 0.7 s.

Gestures:

  • Pinch zoom from 1× up to max(6, extent/radius × 1.2).
  • Pan only when zoomed in.
  • Double-tap resets the view.

Caption:

StateCaption
Wind correction off“Drift X”
On, core distance ≤ 1 m“Centred on the core · Drift X”
On, otherwise“Centred N%” · “Core %1$@ at %2$@” (distance, bearing) · drift
  • Data: the drifted points.
  • Projection: side view, initial azimuth 45°. A horizontal drag rotates it by 0.6° per point. Segments are depth-sorted.
  • Stroke: 3 pt, ramp colour at 0.8 opacity.
  • Axis: labelled with the floor and top altitudes.
  • Info text: “The climb seen from the side as you flew it, drifting with the wind. Swipe across to turn it. This one is for looking at rather than measuring — read the rate off the climb graph and the centring off the plan view.”
  • Caption: “%1$@ gained, topping out at %2$@”.

Code:

  • Packages/FlightScreens/.../EditFlight/{EditFlightView,EditFlightViewModel,EditFlightStoring}.swift
  • Modules/App/Sources/Features/Flight/EditFlight/{CoreFlightEditStore,UIViewController+EditFlight}.swift

All entries are titled “Edit flight” (flight.button.edit, icon square.and.pencil). They exist only for logbook flights; a recents-only or unsaved service flight has none.

  • The flight row’s context menu (list or gallery).
  • The flight map’s overflow menu, flight group.
  • Menu bar: Flight → Edit flight (AppCommand.editFlight).

Not reachable from the detail popup.

  • A form sheet (modalPresentationStyle = .formSheet) inside a navigation controller.
  • Title: “Edit flight” (flight.edit.label.placeholder [App]).
  • Confirmation button: “Done” (common.button.done).

1. Explainer card (tertiary fill)

  • Title: “Good to know”.
  • Message: “These details are only saved in Vulture’s own database — the flight file itself is never modified.”

2. “Pilot information” (flight.edit.label.header.pilot)

  • Text field “Pilot”.
  • Text field “Passenger”. This is the co-pilot field; there is no separate co-pilot.

3. “Glider information” (flight.edit.label.header.glider)

  • Text fields:
    • “Type”
    • “Registration”
    • “Competition number”
    • “Class”
  • Water ballast block:
    • drop icon (secondary tint), “Water ballast”, and the value in liters (Measurement, wide style, e.g. “80 liters”; secondary tint, semibold).
    • Description (caption): “The amount of water you filled the glider with for this flight.”
    • Slider 0…20 in steps of 10 L (liters = Int(slider × 10)), so 0–200 L. Tinted secondary.

4. “Custom information” (flight.edit.label.header.custom)

  • Toggles:
    • “Field landing?”
    • “Contest?”
    • “Favorite?”
  • Remarks block:
    • Title “Flight remarks” (subheadline).
    • Description: “You can add additional remarks to this flight. These remarks will be searchable and visible on the flight list.”
    • A TextEditor, minimum height 100, on a quaternary fill with radius 10.

5. Reset section

  • A red, centred button “Restore from flight file” (flight.edit.reset.button).

  • It opens a menu titled “Restore the pilot and glider details from the flight file?” with a destructive item “Restore” (arrow.counterclockwise).

  • Footer: “Re-read the pilot and glider details from the flight file, overwriting your edits to those fields.”

  • While restoring: a spinner shows next to the label and the whole form is disabled.

  • Restore steps:

    1. Decode the IGC on a background queue.
    2. Write pilot, passenger, type, registration, competition ID and class back to the store.
    3. Reload those six fields.

    Water, toggles and remarks are untouched.

  • No validation. All fields are free text.
  • Saving:
    • The form saves on “Done” and on disappear, for example a swipe-down dismissal.
    • There is no Cancel; edits always persist.
    • Empty strings are stored as nil, so the displays fall back to the IGC header value.

The form has no sport, task, takeoff location or ICAO field. Takeoff location and ICAO are set at import.

The fields shown map onto DetailFlightRecord, which the detail popup displays. Only changes to waterBalast are observed live by the open popup; other edits show up when the popup is reopened.

“Mark flight” (flight.button.mark, checkmark.circle) offers quick toggles:

  • “Favorite”
  • “Contest”
  • “Field landing”

A mark that is on shows checkmark.circle.fill; one that is off shows circle.


Code is mainly in:

  • Shell/Menus/FlightMenuBuilder.swift
  • Shell/Sidebar/List/Configuration/ListViewController+Menu.swift
  • ListViewModel+Share.swift
  • Map/Flight/FlightMapViewController.swift
  • Map/Flight/Flight/FlightViewModel.swift
  • Support/Upload/**
  • IGC
    • The stored IGC text, unchanged, written as UTF-8 to temp/<flight.filename>.
    • The fallback filename is <compId>_<ddMMyyyy>.igc.
    • Drag-and-drop uses dragFilename: stored name → <compId|reg|pilot>_<ddMMyyyy>.igc → Flight.igc.
  • PNG map snapshot (“Map Image”): see §5.4.
  • Tasks only: CUP, LXNAV Nano and FLARM.
  • No KML, GPX or CSV export exists anywhere. There is no “Open in…” either; everything goes through the system share sheet (UIActivityViewController) or drag-and-drop.
Section titled “6.5.2 Flight row context menu (list or gallery)”

Inline groups, in this order:

  1. “Open in New Window” (menu.button.openinwindow, macwindow.badge.plus). iPad and Mac only.
  2. Share group:
    • “Share” (flight.button.share, square.and.arrow.up). Shares the IGC directly.
    • “Upload flight” ▸ (flight.button.upload, arrow.up), containing “SkyLines” / “WeGlide” / “Charron”. Each service appears only if it is connected; the submenu is omitted if none are.
    • “Simulate flight” (DEBUG builds only).
  3. Recents only: “Save to flight log” (folder.badge.plus) and “Remove from Recents” (clock.badge.xmark).
  4. “Mark flight” ▸: Favorite / Contest / Field landing.
  5. Edit group:
    • “Edit flight”.
    • “Delete flight” (trash, destructive). On touch it is a submenu “Cancel” / “Delete flight”; on the Mac it opens a confirmation alert.

Delete confirmation alert:

  • Title: “Delete flight?” / “Delete %u flights?”.
  • Message: “Are you sure you want to delete this flight? This action can not be undone.”
  • Buttons: “Delete” and “Cancel”.

Multi-selection:

  • Menu (Mac right-click on a selection): Open in New Window (more than one flight needs Pro .severalFlights), Share (one IGC per flight in one share sheet), Delete.
  • Touch edit mode:
    • Bar buttons: Select All, Delete, Share, Done.
    • Bottom button: “Open flight” / “Open %u flights”, with the zero form “Select a flight”.
  • No swipe actions on flight rows.

Every item acts on the selected flight.

  • Flight group:
    • “Save to flight log” (only when the flight is not yet in the logbook).
    • “Mark flight” ▸.
    • “Edit flight”.
  • Share group:
    • “Share” ▸:
      • “IGC File” (flight.button.share.file, doc)
      • “Map Image” (flight.button.share.snapshot, photo)
    • “Upload flight” ▸: SkyLines / WeGlide / Charron. This works for unsaved service flights too.
  • Mac: the same items are in the menu bar under File → Share ▸ (“Selection”, “IGC File”, “Map Image”) and Flight → Edit flight / Mark flight / Upload flight.
  • Watch: “Share flight” ShareLink (IGC).

The detail popup has no flight share. Its only actions are Report, and task export on the Task page.

Code: Map/MapViewDataSource+Snapshot.swift, Map/Rendering/Snapshot/FlightSnapshotComposer.swift.

Capture:

  • A Mapbox snapshot with overlays.
  • Cropped to a square: the side is min(width, height visible above the bottom panel).

Drawing (scale factor unit = clamp(width/390, 1, 2.2)):

  • A dark gradient strip at the bottom: rgb(20,16,28), alpha 0 → 0.9.
  • Identity line:
    • A rounded chip in the track colour holding the competition ID (or registration), upper-cased, bold 15·unit.
    • The medium date, plus ” · HH:mm:ss” when a playback position is set (regular 13·unit, white at 85%).
    • A right-aligned “VULTURE” wordmark in the brand colour (semibold 11·unit, kern 1.2).
  • Stat row (only when a playback fix is selected): “SPEED”, “VARIO” (1 decimal), “MSL”, “AGL” (when terrain data is available).
    • Values: monospaced bold 19·unit in white, shrinking to a minimum of 12.
    • Labels: semibold 10·unit, white at 70%.
  • Attribution: a required pill at top-left reading “mapbox © Mapbox © OpenStreetMap”.

Output: temp/<filename without extension>.png, then the share sheet. Not Pro-gated.

A service is “connected” when its credentials are in the Keychain:

ServiceRequires
SkyLinesusername + password + userId
WeGlideAPI key + userId + date of birth
Charronusername + password

All uploads are Pro-gated (ProFeature.uploads). The items stay visible; tapping one opens the paywall.

Progress card (SkyLines and WeGlide):

  • A modal glass card, 300 pt wide, over a 60% black overlay.
  • Shows an icon and a message.
StateIconNotes
LoadingspinnerCannot be dismissed.
Failedexclamationmark.triangle.fill redOptional “Try again” button.
Donecheckmark.circle.fill green—
  • Tapping the overlay dismisses the card once it is done or failed.

Steps:

  1. The card shows “Uploading the flight to SkyLines…”.
  2. POST https://skylines.aero/api/flights/upload
    • Basic auth.
    • Multipart: files = the IGC, pilotId.
    • Status codes: 0 = success and 1 = duplicate both count as OK.
  3. If the “Publish on upload” setting is on: POST /api/flights/{id} with {"privacyLevel":0}.
  4. The card shows “The flight is uploaded to SkyLines.”

Errors (no retry):

  • Future flight: “The flight took place in the future, this is currently not supported.”
  • Parse error: “The flight could not be parsed.”
  • Missing date: “The flight doesn’t contain a date.”
  • No flight: “There was no flight uploaded.”
  • Prepare failure: “Something went wrong trying to prepare the flight for upload.”

Network errors show the system message with “Try again”.

Login is in Settings: username/password validated with GET /api/settings.

Setup: there is no OAuth. In Settings the user enters:

  • API key (from weglide.org Profile → Settings → Advanced → API Key)
  • User ID (number)
  • Date of birth (date picker)

Steps:

  1. The card shows “Uploading the flight to WeGlide…”.
  2. POST https://api.weglide.org/v1/igcfile
    • Header: X-API-Key.
    • Multipart: file = flight.igc, user_id, date_of_birth (yyyy-MM-dd).
    • The certificate is pinned.
  3. The card shows “Adding comment to the flight on WeGlide…”. It sends PATCH /v1/flightdetail/{id} with {"comment":"Uploaded with Vulture"}.
  4. The card shows “The flight is uploaded to WeGlide.”

Errors:

  • A “[406]” error shows “Something went wrong trying to prepare the flight for upload.”
  • Any other error shows the system message.
  • Effectively there is no retry.
  • Photo and story upload code is dead; skip it.

Form sheet:

  • Title: “Charron {season year}”. The season year is year + 1 when the flight’s month is after September.
  • Close on the left; “Save” on the right.

Sections:

SectionFields
“Glider”glider picker (fuzzy-matched to the IGC glider type, from a bundled JSON list); “Registration” (upper-cased, placeholder OO-YBR); “Engine” toggle
“Competition”optional picker; “Distance” (number pad, placeholder 300)
“Flight”read-only filename; declaration toggle “I declare that the given IGC file is downloaded directly from the flight recorder.”

Save is enabled only when the declaration is on, the registration is filled and a glider is selected.

Submit:

  1. Log in to charronline.be with form fields gebruikersnaam / paswoord.
  2. Build a multipart request to invoer2.php (fields immatriculatie, motorzwever, wedstrijd, wedstrijdafstand, typeid, …).
  3. Load that request in an in-app web view; the user finishes on the website.

Errors: an alert “Something went wrong” with the error text and “Ok”.

Trigger: the triangle button in the detail popup or inspector.

When mail is available:

FieldValue
Tome+vulture@fousa.be
Subject“Reporting incorrect flight statistics” (flight.report.email.subject)
Body"\n--\n<AppName> - <version (build)>\n<OS version string>"
Attachmentthe IGC, MIME application/vnd.fai.igc, named flight.filename
Resultignored; there is no confirmation

When no mail account exists: an alert.

  • Title: “Report flight”.
  • Message: “There is no email account configured on this device.”
  • Button: “Ok”.

Android: use ACTION_SEND with the mailto: selector and a FileProvider attachment. Show the same alert when no activity resolves.

Not gatedGated
IGC share, Map Image, report mail, mark, edit, deleteuploads (.uploads); opening more than one flight together (.severalFlights)

FeatureStatusWhat it is
DebriefShipsFully deterministic, no LLM. A rating gauge plus fixed questions answered from the analysis.
Flight Coach chatBuilt but disabled (FlightMapViewController.isFlightCoachEnabled = false, parked 2026-10-06)On-device Apple FoundationModels chat with tool calling. Parked because the model misread figures (read “L/D 54:1” as glide 541) and the 4k-token context was too small. Treat as optional or skip for Android.
Siri App IntentsShips, no in-app UI“Analyse flight”, “Analyse my last flight” and “Compare flights”, via FoundationModels guided generation (iOS 26+). Gated by Pro .debrief.

Entry points:

  • Bar button text.bubble “Debrief” (flight.button.debrief [App]) at regular width.
  • An overflow menu item at compact width.
  • Menu-bar shortcut ⌥⌘D.
  • On the Mac it replaces the detail in the inspector column.

Availability: whenever content is loaded and at least one flight is on the map. There is no OS gate.

Presentation:

ContextPresentation
PhoneA draggable bottom card over the map
iPadA 400×600 popover, with the map still interactive (passthrough)
MacThe inspector column

Pro (.debrief):

  • The score and its band are free.
  • Component scores and facts are blurred, and there is a card reading “Each part of your rating, and the answers to every question, come with Vulture Pro.” with a button “Unlock with Pro” that opens the paywall.
  • Tapping a question while locked opens the paywall.
  • It unlocks live when the subscription becomes active.

Title: “Debrief”.

Content, in order:

  1. Rating gauge (§7) with the flight title “COMPID · Pilot” below it.
  2. Component rows:
    • Title, score, and a chevron (or lock.fill).
    • A 6 pt progress capsule in the band colour.
    • A facts line, with facts joined by ” · ”.
  3. “Other questions” row (questionmark.circle), which pushes the list of questions not tied to a rating component. When there is no rating, all questions are listed inline.

Answer page (pushed; never deeper than list → answer):

  • Title: the question.
  • Rows: title over value (footnote, secondary, monospaced). Rows with a map place have an accent glyph; tapping one selects the flight, moves the glider, highlights the fixes and frames the flight. Accessibility hint “Shows it on the map”.
  • Footer: the summary.
  • “More questions” section: related questions from the same component.
  • Empty state: “This flight has no answer to that.”
  • Loading state: “Reading the tracks…” (only for “Who followed whom”).
  • Following the map: when the map’s selected flight changes, the answers are recomputed.

The question key is flight.debrief.question.*.

  • Single-flight questions (always available): Best thermal, Weakest thermal, Longest glide, Where I lost time, Where I stayed too long, My lowest point, My strongest hour, Left or right, The detour.
  • Single-flight, conditional: The wind (needs wind estimates); The task, leg by leg and My slowest leg (need a task); Engine use (needs engine runs).
  • Multi-flight (more than one flight on the map): Who flew the task fastest, Who had the fastest final glide, My legs against the fastest, Who started when (these four need a task attempt), Who climbed best, Who was highest, Who had the best glide ratio, Who thermalled least, The day’s best thermal, Who flew farthest, Who needed the fewest thermals, Who was lowest, Thermals the winner used and I passed, Who followed whom.

Scope: answers are limited to the task window when a task attempt exists, and summaries are prefixed “On task”. Otherwise they cover the whole flight.

Example algorithms:

  • Time lost: for each thermal weaker than average, duration − gain/averageClimb; the top 5 are listed.
  • Stayed too long: the last third of a thermal climbed at less than half its average climb rate.
  • The other algorithms are in FlightDebrief/FlightQuestionAnswering*.swift.

Bubbles:

  • User: brand colour, trailing.
  • Assistant: grey, leading.
  • Typing dots, labelled “The coach is typing”.

Input:

  • Field “Ask about your flight…”, 1–5 lines.
  • Button “Send”.

Greeting: “Hi! I’ve loaded the data from %@. Ask me anything about your flight — performance, thermals, strategy, or what you could improve.”

Tools exposed to the model: flight_summary, distances, task, compare, thermals, glides, moment, show_on_map.

Error messages:

  • “Something went wrong: %@”
  • “That is more than the coach on this device can take in at once. Try with fewer flights on the map.”
  • “I don’t understand this language on this device yet. Try English.”
  • “No response received. Try rephrasing your question.”

Code: Packages/FlightScreens/.../FlightRating/*.

  • The score runs from 0 to 1000.
  • It is nil when the flight has neither a free distance nor a task attempt.
  • Components are weighted Climbing 0.30, Gliding 0.25, Speed 0.25, Decisions 0.20. A missing component’s weight is spread over the others.
ScoreBandColour
< 400“Rough”red
< 550“Fair”orange
< 700“Solid”yellow
< 850“Strong”green
≥ 850“Outstanding”secondary blue

Climbing

  • Requires at least 4 real thermals.
  • Score = 0.6 × scaled(average climb / best lift, 0.35 → 0.75) + 0.4 × scaled(lost-time share, 0.30 → 0).
  • Facts:
    • ”%@ of your best climb”
    • ”%@ lost in weak climbs”

Gliding

  • Requires a line of at least 10 km.
  • Score = scaled(detour / line, 0.28 → 0.08).
  • Facts:
    • ”%@ detour (%@)”
    • “L/D n:1”

Speed

  • With other flights on the map: ranked against them; 1st scores 100 and last scores 40. Fact: “%d of %d on the day”.

  • Alone: scaled against fixed per-sport ranges:

    SportRange
    Glider50–120 km/h
    Hang glider20–60 km/h
    Paraglider15–45 km/h

    This is the only sport-specific UI behaviour in the app.

Decisions

  • Starts at 100 and subtracts:

    ConditionDeduction
    Low point < 300 m above takeoff−35
    Low point < 500 m above takeoff−15
    Each engine run after release−20
    Abandoned-attempt share > 25%−10
    Height lost again > 10% of height gained−10
  • Facts:

    • ”%@ above take-off at %@”
    • “Engine used for %@”
    • ”%@ of thermals abandoned”
    • ”%@ lost again in thermals”
  • A 220° arc with round caps, line width max(8, 0.08 × width).
  • The score number in rounded bold, with the band word upper-cased below it.
  • Debrief.
  • The Compare flights table: a “Rating” section with a “Score” row and one row per component, ranked as a podium.

  • Setting: “Show your photos”, off by default, logbook flights only.
  • Description: “Show the photos from your Photo Library taken during the flight. (This only works for flights from the flight log)”.
  • Query: PhotoKit images whose creation date falls between the flight’s recording start and end.
  • Map: geotagged photos only, shown as clustered pins (cluster radius 35).
  • Permission: requested implicitly on the first fetch. If it is denied, no photos are shown and there is no message.
  • Tapping a photo pin, or a cluster, takes priority over thermal hit-testing.
  • Presented full screen, forced dark mode, no swipe-to-dismiss (Close button only). On the Mac it opens a separate window.

Navigation bar:

  • Title: the photo’s full date.
  • Subtitle: “%d of %d”, shown when there is more than one photo.

Paging: horizontal paging, black background, aspect-fit images.

Each page:

  • Live Photos play and carry a “LIVE” badge.

  • Top and bottom gradient fades.

  • A readings strip from the flight fix nearest the photo’s time:

    LabelValue
    TIMEHH:MM:SS
    ALTITUDEpressure altitude, falling back to the photo’s GPS altitude
    SPEEDgroundspeed
    CLIMBvario

Pinch: a transient zoom of 1–4× that springs back on release.


ContextBehaviour
Summary or page with no dataRows and sections are omitted. There is no explicit empty state; Flight and Info rows always exist.
AirspaceThe row appears asynchronously once the scan reports crossings. With none, no row is shown. The list title updates live.
Phases filter with no matchesThe section is empty, giving a blank list. There is no empty state; consider adding one on Android.
Edit flight restoreSpinner, form disabled. A decode failure is silently ignored.
Debrief“This flight has no answer to that.”; “Reading the tracks…” while computing; locked/blurred state without Pro.
Thermal detailPro paywall; the plan is empty with fewer than 3 fixes.
PhotosSilent when none are found or permission is denied.
UploadsProgress card states (§5.5).
Report mail“No email account” alert.
Share of a flight with no filename or contentSilently skipped; the list path may open an empty share sheet. This is a bug; don’t copy it.

FeatureGate
Thermal detail sheet.thermalAnalysis
Debrief component detail and answers.debrief (score and band free)
Uploads.uploads
More than one flight on a map / compare / open several.severalFlights
Task creation or editing (turnpoint editing on the task page).taskPlanning
Task override on the flight map.taskOverride
Custom airspace.customAirspace. Affects the openAIP footer on the infringements page (showsOpenAIPAirspace).

Free: the detail popup and all its pages, IGC share, Map Image, edit, mark and report.

Locking rule: a feature is unlocked when !isFreeTierEnabled || isSubscribed. The free tier is enabled in release builds.


6.11 Bugs and quirks worth fixing on Android

Section titled “6.11 Bugs and quirks worth fixing on Android”
  • Label typos:
    • “Competion class” (flight.info.label.competition.class)
    • “Minumum AAT duration” (flight.turnpoints.info.label.minimumtime)
  • Glide ratio on the Flight page is shown as “42.00” instead of “42:1”.
  • Thermal diameter is formatted with the altitude unit.
  • Thermal histogram labels change with knots, but the bucket edges stay in m/s.
  • Left/right “Average vario” columns share one label.
  • Water ballast reads “N liter” in hard-coded English.
  • Temp files are written under their real names and can collide. Use cacheDir plus a FileProvider.
  • WeGlide network errors have no retry.

App module (Modules/App/Sources/):

  • Features/Flight/Detail/
    • DetailPopupController.swift
    • DetailInspectorController.swift
    • DetailTabPageController.swift
    • DetailTabBridge.swift
    • DetailPopupViewModel.swift
    • DetailPopupActionPresenter.swift
    • DetailBarItem+UIKit.swift
    • DetailContent/FlightReportMailPresenter.swift
  • Features/Flight/EditFlight/
    • CoreFlightEditStore.swift
    • UIViewController+EditFlight.swift
  • Features/Flight/Thermal/ThermalDetailViewController.swift
  • Map/Flight/Thermal/ThermalController.swift
  • Map/Flight/FlightMapViewController.swift (info toggle, subscribe, menus, share, snapshot)
  • Map/Extensions/MapInspector.swift
  • Map/Extensions/MapPopoverPresentationDelegate.swift
  • Map/OptimizationDetail/*
  • Map/Task/TaskMapViewController.swift (task popup)
  • Shell/Menus/FlightMenuBuilder.swift
  • Shell/Sidebar/List/Configuration/ListViewController+Menu.swift
  • Shell/Sidebar/List/ListViewModel+Share.swift
  • Support/Upload/**
  • Map/Rendering/Snapshot/FlightSnapshotComposer.swift
  • Application/FlightAI.swift

FlightScreens package (Packages/FlightScreens/Sources/FlightScreens/):

  • FlightDetail/Summary/*
  • FlightDetail/Tabs/*
  • FlightDetail/Rows/*
  • FlightDetail/Models/*
  • FlightDetail/DetailListStyle.swift
  • Models/DetailOptimizationItem*.swift
  • AirspaceInfringements/*
  • EditFlight/*
  • ThermalDetail/*
  • FlightDebrief/*
  • FlightChat/*
  • FlightRating/*
  • AssetPager/*
  • Extensions/{Event+Name,PhaseSelection+Navigation}.swift

Other packages:

  • Packages/Flights/Sources/Flights/Models/Flight.swift (statistic definitions)
  • Packages/Preferences/Sources/Preferences/Units/* (formatting)
  • Packages/Preferences/Sources/Preferences/Pro/ProFeature.swift

Design docs:

  • Docs/flight-detail-summary-redesign.md
  • Docs/flight-detail-inspector-investigation.md
  • Docs/sport-specific-analysis.md

6.13 Android implementation notes for the detail panels

Section titled “6.13 Android implementation notes for the detail panels”
iOS elementAndroid / Compose
Summary page: hero thumbnail, 2-col metric grid, “Analysis” rows with a 32 pt tinted badge, title/caption, tinted trailing headline, chevron, “Remark”LazyColumn with Card-grouped sections (M3 “segmented list” look: ListItem inside Surface(shape = large) with 2 dp gaps; M3 Expressive grouped list shapes where the first and last items are rounded). The hero uses the same TrackThumbnail composable as the list. Metric grid: FlowRow(maxItemsInEachRow = 2) with VerticalDivider; it collapses to 1 column when LocalDensity.fontScale >= 1.5. Analysis row: ListItem(leadingContent = Box(CircleShape, tint.copy(alpha=.15f)) { Icon }, headlineContent, supportingContent, trailingContent = Column { value, detail }).
Page header right item “Report incorrect calculation” (triangle)IconButton(Icons.Outlined.ReportProblem) with a tooltip (“Report incorrect calculation”).
Phases title menu (filter All/Events/Engine/Thermalling/Cruising)Make the title a TextButton with ArrowDropDown, or put a filter IconButton in the app bar. The menu is a single-select DropdownMenu. Alternatively use an M3 Expressive connected ButtonGroup / SingleChoiceSegmentedButtonRow under the app bar (5 options; scrollable on compact).
Bar graphs (flaps, thermal histogram)A small Canvas composable reused for both. 100 dp tall, with labels.
Mac inspector icon tabs (segmented, icon-only)On expanded Android width, use a PrimaryScrollableTabRow with icon tabs, or an M3 Expressive ButtonGroup of ToggleButtons, at the top of the supporting pane. Use the same page order as the summary rows, and remember the last page (inspectorPage).
Optimisation detail and Legs rows with context menu (“Go to turnpoint”, “Go to this leg”)ListItem with combinedClickable. Long-press opens a DropdownMenu.
Thermal detail card: ✕ title ← →, Climb/Centering/3D segmented, charts, data gridThe card is a draggable companion (§5.9). Header: IconButton close + a title button + an M3 Expressive connected ButtonGroup with ←/→. Use SingleChoiceSegmentedButtonRow for Climb/Centering/3D (or the Expressive ButtonGroup with ToggleButtons). Draw the charts with Canvas. Plan chart: detectTransformGestures + double-tap reset. Helix: horizontal drag to rotate, with depth sorting done in Kotlin. Info headers use a dotted underline (drawBehind dash path) and open RichTooltips. “Wind correction” is a Switch (tint = Tint Secondary).
Edit flight form sheet (explainer card, text fields, water slider 0–200 L step 10, toggles, remarks editor, Restore button with confirm menu, saves on Done and on dismiss, no Cancel)Compact: a full-screen dialog. Expanded: a ModalBottomSheet or Dialog. Use OutlinedTextFields and an M3 Expressive Slider with steps = 19 and value-indicator label. Toggles are ListItem(trailing = Switch). Restore is a TextButton(colors = error) that opens a confirm DropdownMenu, with a LoadingIndicator while restoring. Save on Done and in DisposableEffect.onDispose / back press, to keep the “no Cancel, always persists” parity.
Upload progress card (spinner / failed / done)AlertDialog-shaped Dialog with a LoadingIndicator, then a success/error icon and an optional “Try again” button. Non-dismissable while loading.
Charron upload form, then in-app web viewA form screen, then WebView in a dialog (or a Custom Tab, but the POST body requires a WebView postUrl).
Map Image composerA Mapbox Snapshotter, then draw on an android.graphics.Canvas (gradient strip, chip, wordmark, stats, attribution). Write to cacheDir, share through FileProvider + Intent.ACTION_SEND with type image/png.
IGC shareFileProvider URI with MIME application/vnd.fai.igc (fall back to text/plain for receivers that filter). Intent.createChooser.
Report mailIntent(ACTION_SEND) with selector = Intent(ACTION_SENDTO, "mailto:"), the IGC attached. No resolver → AlertDialog “Report flight / There is no email account configured on this device.”
Debrief (gauge, component rows with 6 dp capsules, blurred locked state, “Unlock with Pro”)Gauge = a Canvas arc (220°). Capsules = LinearProgressIndicator (or the Expressive LinearWavyProgressIndicator, static) in the band colour. Locked state = Modifier.blur(8.dp) (API 31+; on older versions fall back to alpha 0.3 + a scrim) with a Card and a Button “Unlock with Pro”.
Photos pager (full-screen dark, Live Photo, readings strip, pinch 1–4× spring back)HorizontalPager in a full-screen dialog with the dark theme forced. Photos come from MediaStore filtered by DATE_TAKEN and location (needs READ_MEDIA_IMAGES + ACCESS_MEDIA_LOCATION). Motion photos can be skipped or shown as stills.
Flight Coach chat (disabled on iOS)Skip for v1. If wanted later, Gemini Nano via ML Kit GenAI / AICore is the on-device analogue.

CatalogKeysUsed byNotes
Packages/DesignSystem/Sources/DesignSystem/Resources/Localizable.xcstrings957All screen packages (FlightScreens, ImportScreens, …). Prefixes: flight.* (356), settings.* (156), subscription.* (73), map.* (60), task.* (41), thermal.* (28), file.*, airspace.*, list.* (20), bluetooth.* (19), error.* (16, incl. error.state.*), accessibility.* (17), common.* (15), selection.*, photo.*, …9 plural keys
Modules/App/Resources/Localizable.xcstrings510Shell, menus, map controllers. Prefixes: flight.* (144, incl. many flight.button.* such as Compare, Debrief, Ranking, Share ▸ IGC File/Map Image), task.* (57), inflight.* (53), accessibility.* (35), menu.* (31), sidebar.* (17), recents.*, weglide.*, skylines.*14 plural keys. Both catalogs are needed for the flight screens.
Packages/Formatting/.../Localizable.xcstrings24Decoder error messages, etc.
Packages/Preferences/.../Localizable.xcstrings39Unit symbols, graph unit titles, play speeds
Modules/App/Resources/InfoPlist.xcstrings18Permission rationales (Bluetooth, location, photos, camera)Maps to Android permission-rationale strings
Modules/App/Resources/AppShortcuts.xcstrings12Siri phrasesMaps to App Actions shortcuts.xml
Modules/Watch, QuickLook, Clip, MetarWidget, LiveActivity—Out of scope
  • 8 locales: en (source, US English), cs, de, es, fr, it, nl, pl.

  • Every key in the DesignSystem and App catalogs is present in all 8. The only gap is 2 App keys that exist in English only.

  • LOCALIZATION_STYLE.md also mentions Slovenian, but there is no sl in any catalog yet.

  • Voice rules from LOCALIZATION_STYLE.md that Android copy must follow:

    • informal “you” (du / tu / ty / je)
    • sentence case
    • no trailing period on labels
    • aviation units never translated
    • plurals always through plural variants
  • Android resource folders:

    LocaleFolder
    csvalues-cs
    devalues-de
    esvalues-es
    frvalues-fr
    itvalues-it
    nlvalues-nl
    plvalues-pl
    envalues (default)
  • Declare the locales in res/xml/locales_config.xml for per-app language (Android 13+).

7.3 Converting .xcstrings to Android resources

Section titled “7.3 Converting .xcstrings to Android resources”

Write a one-off script (Python or Kotlin) that reads the JSON and writes per-locale strings.xml and plurals.

  1. Key names. Convert the dotted keys to Android names: flight.statistics.data.duration becomes flight_statistics_data_duration. Keep a mapping file so the iOS and Android keys stay traceable.

  2. Plurals.

    • Convert variations.plural.{zero,one,few,many,other} to <plurals>.
    • Czech and Polish use few/many, and those forms are already present in the catalogs.
    • Android ignores zero for English. Strings like “No flights” (flight.label.subtitle zero form) or “No infringements” need a separate <string> plus code that picks it when the count is 0.
  3. Format specifiers.

    iOSAndroid
    %@%s
    %1$@%1$s
    %u, %lld%d
    %0.2f%.2f
    • Escape ', ", @ (at the start of a value), ? and % (as %%). Use formatted="false" where needed.
    • DesignSystem English has 68× %@, 7× %1$@, 13× %d. App English has 21× %@ plus positional %1$@–%4$@. If a string has several arguments without positions, add positions so translators can reorder them.
  4. Proper nouns. Brand and service names (“WeGlide”, “SkyLines”, “Soaring Spot”, “Charron.line”, “Vulture”) stay untranslated. iOS sometimes hard-codes them; on Android use translatable="false" resources.

  5. Known source typos. Fix them on Android, or keep them for parity, but decide which:

    • “Competion class”
    • “Minumum AAT duration”
    • “befor the selected flights”
    • “Balasted” / “water balast”
    • “N liter” is hard-coded English.
  6. Unit symbols and number formatting.

    • iOS uses String(format:) with the user’s units (decimals per unit, see §6.0b). Port the UnitFormatter rules exactly.
    • Use NumberFormat/DecimalFormat with the app locale, so the decimal separator follows the locale. The catalogs never hard-code it.
  7. Date formats.

    • EEE d MMM (list rows)
    • medium date (Recents, detail title)
    • full date (hero subtitle, photos)
    • HH:mm:ss (fix times)
    • “m:ss” (thermal ticks)

    Use DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) and DateFormat.getBestDateTimePattern(locale, "EEEdMMM").


Conventions.

  • One feature module per area: :feature:flights, :feature:import, :feature:flightmap.
  • Each screen has a @Composable XScreen(state, onAction) plus a stateless XContent, and an XViewModel (Hilt, StateFlow<XUiState>, fun onAction(XAction)).
  • Each screen gets a small per-screen repository interface mirroring the iOS <Screen>Providing protocol, with fakes shared by @Preview and tests (the iOS Mock<Dependency> pattern).
  • Component names are Material 3 / M3 Expressive (androidx.compose.material3 1.4+ / 1.5 alpha). Expressive-only APIs are marked (E).
#iOS screen / file(s)Compose screenViewModelM3 / M3 Expressive componentsNotes / glass → Android
1SplitViewController, SidebarViewController, SidebarView, SidebarViewModel (sidebar: Explore map, Recents, Library ×4 with counts, Discover ×5, “Start flying”)AppShell + LibraryScreen (compact root)ShellViewModel (destination, persisted), LibraryViewModel (counts, last-flight subtitle)NavigationSuiteScaffold (custom NavigationSuiteType: none on compact, WideNavigationRail (E) collapsed/expanded on medium, PermanentNavigationDrawer-like WideNavigationRail expanded on large), LargeFlexibleTopAppBar (E) “Vulture” + last-flight subtitle, ListItem with trailing count, collapsible section headers, ExtendedFloatingActionButton “Start flying”Sidebar translucency becomes a tonal surface (surfaceContainer). Selection: NavigationDrawerItem-style active indicator; on compact, no persistent selection (chevrons).
2ListViewController + ListViewModel + SidebarListView + FlightListRow (Logbook, Recents)FlightListScreen(config = Logbook | Recents)FlightListViewModel (query: sort, filter, search; layout; selection)TopAppBar/MediumFlexibleTopAppBar (E) with title + count subtitle; LazyColumn with year stickyHeader-like (non-pinned) headers; ListItem; figure chips as small Surface(CircleShape); Badge dot; compact bottom HorizontalFloatingToolbar (E) (filter, search, +); FloatingActionButtonMenu (E) for + ▸ Files / Bluetooth device / Wi-Fi; overflow DropdownMenu (Sort By ▸ with DropdownMenuGroup, View as Gallery/List, Select, Clear Recents)Glass bottom toolbar → floating toolbar. The iOS system overflow becomes an explicit MoreVert. No swipe actions, for parity.
3SidebarGalleryView, GalleryCard, GalleryTile, TrackThumbnailTile, GalleryArtworkGenerator (Mapbox snapshots)FlightGallery (inside FlightListScreen)same VMLazyVerticalGrid(GridCells.Fixed(n)), Card (radius 18, aspect 1:0.92), Modifier.animateItem(), overflow zoom itemsGallery is the default layout. Snapshots come from Mapbox Snapshotter, cached in filesDir/artwork/{light,dark}. The thin gradient border + shadow → OutlinedCard with elevation, or keep a custom border.
4SidebarListPreview (long-press preview) + ListViewController+Menu (context menu)FlightQuickLookSheet– (uses row state)ModalBottomSheet with the preview header + metric grid + action list (Share, Upload ▸, Mark ▸, Edit, Delete)Android has no peek-and-pop, so long-press opens the sheet. Alternatively a DropdownMenu (no preview).
5ListFilterView / ListFilterViewModel (countries + 8 criteria tiles, live apply)FlightFilterSheetFlightFilterViewModelModalBottomSheet (compact) / anchored Popup card (expanded); FilterChip in FlowRow, or ToggleButton (E) tiles in a 3-col gridGlass tiles → tonal toggle buttons. The active-filter cue is a filled IconButton. Consider adding “Clear” (deviation).
6Select mode (invalidateEditing, SelectSessionControls, SelectionTick)part of FlightListScreensame VM (selection: Set<Id>)Contextual TopAppBar (count, Select all, Delete, Share, Done); bottom large Button “Open N flights” (E large size); Checkbox or RadioButton-like tick icons.glassProminent button → filled Button with ButtonDefaults.LargeContainerHeight (E).
7FlightsStatisticsViewController (UIKit grid) + FlightsStatisticsViewModelFlightStatisticsScreenFlightStatisticsViewModelFull-screen route; LargeTopAppBar “Statistics” + close; shared ComparisonGrid composable (frozen label column, year header pills as AssistChip/Surface), podium badgesPro-gated. Reuse the Compare grid (#17).
8Empty states (SidebarListPlaceholderView, ContentUnavailableView, .placeholderAction())EmptyState component–Column icon + titleLarge + bodyMedium + FilledTonalButton (glass capsule equivalent)Shared in :core:designsystem.
#iOS screen / file(s)Compose screenViewModelComponentsNotes
9Document pickers (SplitViewController+Import)– (activity-result launchers)ImportViewModel (shell-scoped; batches, errors)OpenMultipleDocuments launcherImport runs in WorkManager/coroutine. Silent on success, as iOS (decisions §6).
10Open-in / share / deep links (SceneDelegate+Routing)– (intent handling in MainActivity)ShellViewModel.handle(intent)AlertDialog “Replace?” (Add / Replace / Cancel)App Links on vulture.aero + vulture://.
11ImportFlightsView / ImportFlightsViewController (WeGlide / SkyLines browser)ServiceFlightsScreen(service)ServiceFlightsViewModel (scope, date, paging, search, selection)TopAppBar title + scope subtitle, filter IconButton + DropdownMenu; PullToRefreshBox with LoadingIndicator (E); Paging 3; ListItem; bottom HorizontalFloatingToolbar (E) with search, date (DatePickerDialog) and an “Open N flights” FAB-styled button; EmptyState / ErrorStateConnect prompt = EmptyState + “Open Settings”.
12BluetoothDevicesView (scanner)BluetoothDevicesScreenBluetoothDevicesViewModelScaffold + TopAppBar (“Select a device”, close, LoadingIndicator while scanning); ListItem with LXNAV logo; “Unsupported” section disabled; radio-off EmptyStateRuntime permissions (BLUETOOTH_SCAN/CONNECT).
13DeviceSendView + BluetoothSendDataViewModel / FlarmSendDataViewModel (status, progress, NMEA footer, alerts)DeviceTransferScreen(transport = Ble | Flarm, type = Flight | Task | Declaration)DeviceTransferViewModel (+ foreground service)Centred status + LinearWavyProgressIndicator (E, determinate) / LoadingIndicator (E, indeterminate); footer text; AlertDialogs “Complete”, “Something went wrong” (Try again / Cancel), “Missing information”Foreground service connectedDevice with a progress notification replaces the iOS local notifications. FLARM needs bindProcessToNetwork.
14DeviceFlightsView (“Select a flight” sheet)DeviceFlightsSheet(part of #13 VM)ModalBottomSheet (non-dismissable while relevant: sheetGesturesEnabled=false) + LazyColumn of ListItems (filename, date, size)Add an empty state (iOS TODO).
15ErrorListView / single-error alert (UIViewController+Error)ImportErrorsDialog–AlertDialog (one error) / full-screen Dialog with a list (several)
#iOS screen / file(s)Compose screenViewModelComponentsNotes
16FlightMapViewController + FlightMapView + FlightMapViewModel + Panel/** (map, glass panel, chips, barogram, play/unit controls, pills, overflow, drop overlay)FlightMapScreenFlightMapViewModel (flights, selection, time, playback, alignment, task override, ranking, satellite) + BarogramState (zoom, handle)MapboxMap; custom AnchoredDraggable floating panel; LazyRow chips; custom Canvas barogram; SplitButtonLayout (E) for Play ▸ speeds; cascading DropdownMenu for the unit; top-end HorizontalFloatingToolbar (E) for clouds / compare / debrief / ranking / info / more; LoadingIndicator (E) HUD; SmallFloatingActionButton show-graphLargest screen (≈ 6–7 k loc on iOS). The glass panel and pills become tonal translucent surfaces (§9).
17CompareFlightsViewController (UIKit) + CompareFlightsViewModel* + FlightRatingCompareFlightsScreenCompareFlightsViewModel (columns, pin, filters, podium/diffs, async analyses)Full-screen route (sheet-like on compact = full-screen dialog); TopAppBar “Compare flights” + close + filter menu; custom ComparisonGrid; header InputChips with pin; RichTooltip info; podium Surface pills; rating gauge + mini barsShared grid with Statistics (#7).
18RaceRankingView / RaceRankingViewControllerRaceRankingPane (companion)part of FlightMapViewModel (rankingState)LazyColumn + animateItem; ListItem with circle badge; section headers “By average speed” / “Aligned by task start” / “Not started”Companion card on compact, supporting pane on expanded.
19DetailPopupController + DetailSummaryView / DetailSummaryViewModelFlightDetailPane → DetailSummaryScreenFlightDetailViewModel (snapshot of analysis + record + live airspace flow)ModalBottomSheet (compact) / SupportingPaneScaffold (expanded) with a nested NavHost; LazyColumn grouped cards; metric grid; analysis ListItems with tinted circle badges; IconButton reportMac-style icon tabs (ButtonGroup (E)) on expanded only.
20DetailTabFlightViewModel pageDetailFlightPageshared FlightDetailViewModel (page projections)Two-column info rows, bar Canvas (flaps)
21DetailTabPerformanceViewModel pageDetailPerformancePage″optimisation rows with chevronTap → highlight on map + dismiss sheet.
22DetailTabThermalsViewModel pageDetailThermalsPage″info rows + histogram Canvas
23DetailTabPhasesViewModel pageDetailPhasesPage″ (+ persisted filter, scroll per flight)phase ListItems with tinted badge + unread dot; filter DropdownMenu / SingleChoiceSegmentedButtonRowAdd an empty state (iOS has none).
24DetailTabTaskViewModel page (declared task)DetailTaskPage″metric grid, turnpoint rows, share-task menuEditing turnpoints belongs to the Task feature (out of scope).
25OptimizationDetailView / OptimizationDetailViewModel (+ mini map)OptimizationDetailScreenOptimizationDetailViewModelstatic MapboxMap 200 dp card; metric grid; Legs ListItems + long-press menu
26AirspaceInfringementsViewAirspaceInfringementsPageAirspaceInfringementsViewModel (live flow)ListItem with class swatch + clock duration
27DetailTabInfoViewModel pageDetailInfoPageshared VMinfo rows
28ThermalDetailView / ThermalViewModel + charts (ThermalProfileChart, ThermalPlanChart, ThermalHelixChart)ThermalDetailPane (companion)ThermalDetailViewModel (index, paging set, chart tab, scrub)Draggable card; header with close + connected ButtonGroup (E) ←/→; SingleChoiceSegmentedButtonRow; 3 Canvas charts; data grid Card; RichTooltipPro .thermalAnalysis.
29EditFlightView / EditFlightViewModelEditFlightScreenEditFlightViewModelFull-screen dialog / sheet; OutlinedTextFields; Slider (steps); Switch rows; remarks multi-line field; destructive restore + LoadingIndicator (E)Saves on Done and on dismiss.
30FlightDebriefView / FlightDebriefViewModel (+ answer pages)DebriefPane → DebriefAnswerScreenDebriefViewModelgauge Canvas; component rows with LinearProgressIndicator; blurred Pro lock + “Unlock with Pro” Button; answer ListItems with map glyphCompanion card / supporting pane.
31AssetPagerView (photos)FlightPhotosScreenFlightPhotosViewModelfull-screen dark Dialog + HorizontalPager, readings strip, zoomMediaStore permissions.
32Upload flows (SkyLinesUploadViewModel, WeGlideUploadViewModel, CharronUpload*)UploadProgressDialog, CharronUploadScreenUploadViewModel, CharronUploadViewModelDialog with LoadingIndicator (E) / result; form + WebViewPro .uploads.
33Map Image (FlightSnapshotComposer)– (use case)FlightMapViewModel.shareSnapshot()Share sheet via Intent.createChooser
34FlightChatView (disabled)———Skip for v1.

9. Translating iOS 26 Liquid Glass and UIKit idioms to Android

Section titled “9. Translating iOS 26 Liquid Glass and UIKit idioms to Android”

Principle: keep the behaviour identical and render it the Android-native way. Do not imitate glass refraction.

iOS idiomWhere it appears in this areaAndroid-native equivalent
Liquid Glass bar-button “pods” (grouped trailing buttons)List nav bar, map nav barTopAppBar actions. On the map, an M3 Expressive HorizontalFloatingToolbar gives the floating grouped look natively.
System overflow … (setOverflowItems, deferred menu, keepsMenuPresented)List, map, sidebarIconButton(Icons.Default.MoreVert) + DropdownMenu. For “keeps menu presented” (sort, zoom), leave expanded true after onClick. Submenus use M3 1.4 cascading menus.
Glass floating panel over the mapFlight map bottom panel, thermal/debrief/ranking cardsSurface with surfaceContainerHigh at about 90 % alpha, shape = extraLarge, 8 dp inset. On API 31+ an optional RenderEffect blur of the map behind it is possible but costly over a SurfaceView/TextureView map, so use plain translucency.
Glass capsule buttons (.glass(), .placeholderAction(), .glassProminent)Empty states, “Open N flights”, Mac addFilledTonalButton (regular) and Button (prominent). Use M3 Expressive button sizes (ButtonDefaults.MediumContainerHeight / shapes that morph on press).
Glass tilesFilter grid, compare headers, statistics year pillsTonal ToggleButton (E) / FilterChip / AssistChip.
UISearchController integrated in the bottom toolbar, .searchable minimizeList, service browserM3 SearchBar (docked in the app bar on expanded) or a search icon in the floating toolbar that expands into ExpandedFullScreenSearchBar on compact.
Popover from a bar item that adapts to a sheet on compact (.presentationCompactAdaptation)Filter, detail, companionsCompact: ModalBottomSheet. Expanded: an anchored Popup / DropdownMenu-like card, or the supporting pane.
Inspector columnDetail on iPad/MacSupportingPaneScaffold supporting pane.
Context menu with preview (peek)List rowsLong-press → ModalBottomSheet with the preview and actions, with a haptic (HapticFeedbackType.LongPress).
Zoom navigation transition from a card (.detailZoom)Gallery → mapShared element transition (SharedTransitionLayout + sharedBounds) from the card to the map, with M3 Expressive motion springs (MotionScheme.expressive()). Rows push with the standard predictive-back-aware slide.
Large title / .browser nav style with subtitleList, sidebarLargeFlexibleTopAppBar / MediumFlexibleTopAppBar (E) with subtitle.
SF SymbolseverywhereMaterial Symbols (Rounded) via material-icons-extended or downloaded font icons. Build a symbol mapping table (airplane.path.dotted → flight/custom, tornado → custom vector, crown.fill → workspace_premium, car.fill → directions_car, drop.fill → water_drop, heart.fill → favorite, …). Some glider-specific glyphs (thermal turn arrows, glider/delta/parapente) need custom vectors.
Haptics (UISelectionFeedbackGenerator on scrub, tap haptics)Barogram scrub, chip selectHapticFeedbackType.SegmentFrequentTick (scrub), SegmentTick / ContextClick (select).
UIActivityViewControllerShare IGC / PNGIntent.createChooser(ACTION_SEND/SEND_MULTIPLE) + FileProvider.
Mac menu bar, multi-window, HandoffMany commandsKeyboard shortcuts via onKeyEvent + KeyboardShortcutGroup. Multi-window via FLAG_ACTIVITY_LAUNCH_ADJACENT on large screens (optional). No Handoff equivalent (skip).
Accent colours–Brand Tint #DF774A (orange) = M3 primary. Tint Secondary #587899 / #5C7EA1 = secondary, also used for toggles (user-confirmed exception). Tint Prominent #B9552C. Destructive #ED5656. Unread #C28709 / #F7C251. Favorite #ED5656. Contest #BD8907 / #F6B91E. Car #5D4C37 / #BD9B72. Water #587899 / #5C7EA1. Thermal #488165 / #69AB8B. Infringement #E71C1C / #ED5656. Flight palette 1–20 (§5.5.2). Disable dynamic colour (Material You) for the brand and data colours, or limit it to neutral surfaces, since flight and thermal colours carry meaning.
systemFill grey “picked row”Select modeMaterialTheme.colorScheme.surfaceContainerHighest, or secondaryContainer at low alpha. Keep “a picked row is grey; the accent only on the tick”.

10. Risks, open decisions and size estimate

Section titled “10. Risks, open decisions and size estimate”
RiskWhyMitigation
Barogram fidelity and performance≈ 3 000 loc of bespoke Core Graphics: bitmap caching, badges, event pills, zoom and scroll, ±40 pt handle grab, multi-flight traces, live sync with the map at 60 fps during playbackBuild it first as an isolated :core:barogram module with screenshot tests (Paparazzi/Roborazzi) against the iOS screenshots. Cache paths per zoom level. Drive the handle from a State<Long> time, not from recomposing the whole graph.
Map + panel + playback syncOne time source drives gliders, chips, pills, ranking, satellite frames and the handleA single StateFlow<Instant> in FlightMapViewModel. Mapbox annotations updated through ViewAnnotation/PointAnnotationManager diffing, not by re-adding features.
Hidden/accidental iOS defaults10× play speed, Pressure altitude unit, gallery as default layout, silent duplicate import, unread on importCopy the effective behaviour listed here, not the declared defaults.
Bottom panel ≠ Material sheetTwo-state floating card with velocity and midpoint snap. Interacts with the map, handle gestures and predictive back.A custom AnchoredDraggable. Gesture-priority tests (handle vs panel vs map).
No peek/preview, no popovers on AndroidList context-menu preview, filter popover, Compare info popoversBottom sheets and RichTooltip. Document these as accepted deviations.
Bluetooth LXNAV and FLARM Wi-FiBLE background behaviour, Android 12+ permissions, and the FLARM AP without internetForeground service. bindProcessToNetwork. Cleartext config for 10.10.10.10. Hardware test devices needed.
IGC MIME typeAndroid has no UTI system, and many apps send application/octet-streamAccept */* on SEND/VIEW, then sniff the content. The Files picker opens with */* and filters afterwards.
Universal linksNeed assetlinks.json on vulture.aeroBackend task. Coordinate with 04-integrations.md.
StringsPlural zero forms, %@ conversion, two catalogs, typosA conversion script plus a CI check that all 8 locales have every key (the iOS jq check).
Pro gating parityGates are scattered (several flights, thermal, debrief, uploads, override, statistics, SkySight)Central ProFeature enum and ProGate composable helper; see 03-persistence-settings-sync.md.
Snapshots for gallery thumbnailsHundreds of Mapbox snapshots, generated in the backgroundSnapshotter in a WorkManager worker with concurrency 1–2, with a disk cache keyed by flight id, theme and size. Show the polyline-only placeholder until the snapshot is ready.
Scope creep from doc-only ideasNotes-style tabs, filter chips, 3D, Flight CoachExplicitly out of v1 parity.
  1. Keep the iOS “push the map over the list” on tablets, or use ListDetailPaneScaffold? (Parity says push.)
  2. Phone detail popup: a modal sheet (iOS today) or a non-modal partially expanded sheet (iOS planned “step 6”)?
  3. Play control: a split button (more Android-native) or parity with the tap-to-menu / long-press idiom?
  4. Should silent operations get feedback, such as a Snackbar after import and a “duplicate” notice? Decided: no (decisions §6).
  5. Fix the source typos and odd formats (“42.00” glide ratio, thermal diameter in the altitude unit, “N liter”)?

10.3 Size estimate (flight UX only; excludes the domain/analysis engine, persistence, map style and layers, and integrations’ networking, all covered in docs 01/03/04/05)

Section titled “10.3 Size estimate (flight UX only; excludes the domain/analysis engine, persistence, map style and layers, and integrations’ networking, all covered in docs 01/03/04/05)”
BlockScreensRough effort (1 experienced Compose dev)
Shell: library/sidebar, adaptive navigation, deep links/intents, keyboard shortcuts1–22–3 weeks
Flight list: list + gallery + thumbnails, sort, filter, search, select, menus, drag/drop, empty states, Recents43–4 weeks
Statistics + Compare grid (shared component), rating gauge22–3 weeks
Import: picker/intent pipeline, error UI, WeGlide/SkyLines browser, BLE (LXNAV) + FLARM transfer screens + foreground service64–5 weeks (the BLE/FLARM part needs hardware)
Flight map screen: panel, chips, barogram, playback, alignment/override, pills, overflow, drop, HUD, satellite toggle, snapshot share1 (big)7–9 weeks
Detail pane: summary + 7 pages + optimisation detail + infringements103–4 weeks
Thermal detail (3 custom charts), Debrief, Ranking, Photos44–5 weeks
Edit flight, uploads (SkyLines/WeGlide/Charron UI), report mail41.5–2 weeks
String conversion pipeline, icons mapping, theme/tokens, components library–1.5–2 weeks
Total≈ 34 screens/panes≈ 28–37 dev-weeks (≈ 7–9 months for one developer; ≈ 4–5 months for two working in parallel, with the map/barogram on the critical path). Add ≈ 20 % for QA, accessibility (TalkBack) and tablet/foldable polish.

For reference, the iOS source in scope is about 28 k loc in FlightScreens and ImportScreens plus about 18 k loc of flight-related app-module code (Shell list, Map/Flight, Features/Flight, Support/Upload).