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
UISplitViewControllershell (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.
Contents
Section titled “Contents”- Key findings and parity traps
- Sources read and screenshots
- App shell and navigation (+ Android mapping)
- Flight list (Logbook / Recents / gallery / filter / search / select / statistics)
- Getting flights in (import)
- Flight map screen: map, barogram, replay, compare, ranking, optimisation
- Flight detail panels, thermal detail, edit, export/upload, Debrief, rating, photos
- Strings and localisation
- Screen inventory: iOS → Compose screen / ViewModel / M3 Expressive components
- iOS 26 Liquid Glass and UIKit idioms → Android-native equivalents
- Risks, open decisions and size estimate
0. Key findings and parity traps
Section titled “0. Key findings and parity traps”- 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
NavHostper destination. AListDetailPaneScaffoldwould be a visible deviation.
- 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
ibutton: 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).
- The default flight-list layout is the gallery, not the list (map-snapshot cards).
- No swipe actions exist on flight rows. Everything is a context menu.
- 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).
- 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.
- 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).
- More than one flight requires Pro (
- 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.
- Track colouring. The track is coloured by altitude only (11-stop red→blue gradient), or plain.
- 3D does not exist (
Docs/3d-map-investigation.mdis investigation only). The only “3D” in the app is the thermal helix chart. - 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×.
- Flight Coach (LLM chat) is built but disabled. Debrief is deterministic and ships.
- 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%@→%sand positional args.
- Strings live in two String Catalogs (
1. Sources read and screenshots
Section titled “1. Sources read and screenshots”1.1 Code and docs
Section titled “1.1 Code and docs”| Area | Where |
|---|---|
| Architecture / design conventions | CLAUDE.md § “SwiftUI architecture — the screen packages”, “Design conventions (iOS 26)”, “Keeping a List lazy”, “Hosting controller pitfalls”; LOCALIZATION_STYLE.md; Docs/hig-compliance.md |
| Shell | Modules/App/Sources/Shell/** (Split, Sidebar, Sidebar/List/**, Menus, DragAndDrop, Navigation) |
| Flight list UI | Packages/FlightScreens/Sources/FlightScreens/FlightList/**, ListFilter/**, Statistics/** |
| Import | Packages/ImportScreens/**, Modules/App/Sources/Shell/Split/SplitViewController+Import.swift, SplitViewModel.swift, Application/SceneDelegate*.swift, Modules/App/Resources/Info.plist |
| Flight map | Modules/App/Sources/Map/Flight/** (FlightMapViewController 2183 loc, FlightMapViewModel 1705 loc, Panel/Graph/** ≈ 3 000 loc), Map/OptimizationDetail/** |
| Detail popup | Modules/App/Sources/Features/Flight/**, Packages/FlightScreens/Sources/FlightScreens/FlightDetail/**, ThermalDetail/**, EditFlight/**, FlightDebrief/**, FlightRating/**, AssetPager/**, AirspaceInfringements/** |
| Compare / ranking | Packages/FlightScreens/Sources/FlightScreens/{Compare,RaceRanking,Hosting/Compare,Hosting/RaceRanking}/** |
| Design docs | Docs/flight-detail-summary-redesign.md, flight-detail-inspector-investigation.md, sport-specific-analysis.md, 3d-map-investigation.md, notes-style-navigation-investigation.md |
| Shared components | Packages/DesignSystem/Sources/DesignSystem/{Components,Selection,Screens}/** |
1.2 What the App Store screenshots show
Section titled “1.2 What the App Store screenshots show”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),iand…. - 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-07performanceshows 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-01is 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>").
2. App shell and navigation
Section titled “2. App shell and navigation”Current design.
Docs/notes-style-navigation-investigation.md§8–9 describes a tab-bar/UITabGroupshell built on the separaterefactor/uxbranch. The code in this tree is the other route (refactor/sidebar): a two-columnUISplitViewController(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.
2.1 Root container
Section titled “2.1 Root container”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.
- Primary column:
- Display mode:
- Default is
.oneBesideSecondary. - The mode is persisted in
Defaults[.splitDisplayMode]. presentsWithGesture = true.- The Mac uses
.tile.
- Default is
- 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
ibutton (⌘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”.
- Alert “Updates available” (
- Interrupted-recording recovery:
- Alert “Continue flight?”
- Buttons: Continue and Discard (destructive).
2.2 Detail destinations
Section titled “2.2 Detail destinations”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.
| Destination | Detail column shows |
|---|---|
.list | One 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. |
.map | The “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:
| Value | Raw value | Title |
|---|---|---|
.logbook | flights.logbook | Flights |
.recents | flights.recents | Recents |
.planner | tasks.planner | Tasks |
.waypoints | waypoints.files | Waypoints |
.airspace | airspace.files | Airspaces |
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.
2.3 Sidebar
Section titled “2.3 Sidebar”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.
- Library (
| Section | Row (key) | Icon | Count | Tap |
|---|---|---|---|---|
| — | Explore map (sidebar.label.shortcuts.map) | map | – | Empty map. On phone, zooms out of the row. |
| — | Recents (sidebar.label.recents.title) | clock | none | showList(.recents) |
| Library | Flights (sidebar.label.flights.log) | airplane.path.dotted | Logbook flights (data != nil) | showList(.logbook) |
| Library | Tasks (sidebar.label.tasks.all) | asset “Task” | yes | Tasks list |
| Library | Airspaces (sidebar.label.shortcuts.airspace) | square.3.layers.3d | yes | Airspace files list |
| Library | Waypoints (sidebar.label.shortcuts.waypoints) | mappin.and.ellipse | yes | Waypoint files list |
| Discover | Files (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. |
| Discover | Soaring Spot / WeGlide / Charron.line / SkyLines | assets | – | 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).- Checks the Pro gate
.recording. - Shows the overlay “Starting flight…”.
- Asks for location and altimeter permission.
- On success, replaces the root with the in-flight map.
- On failure, shows the alert “Location usage disabled” (Open Settings / Ok).
- Checks the Pro gate
- 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.
2.4 Layout per window class
Section titled “2.4 Layout per window class”| iPhone / compact | iPad / regular | Mac | |
|---|---|---|---|
| Structure | One 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 target | The 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 destination | Push (map: zoom transition) | Swap column without animation | Swap |
| List chrome | Bottom 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.
2.5 How a flight opens
Section titled “2.5 How a flight opens”ListViewController+Actions.swift
- A tap opens the flight (a double-click on the Mac). It builds
FlightMapViewController.controller(with: .init(decodableFlights: [flight])). - If the task editor has unsaved work (
BlockingController), an alert asks first. Its Discard button is destructive. - 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. - 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.
- Gallery card: a zoom transition (
- Once open, the flight is marked read (
isRead = true) and stampedlastOpenedAt, which adds it to Recents. Recents keeps the last 50 flights. - Coming back to the list clears the highlight and the selection.
- 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). - 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:)).
2.6 Multi-window and Handoff (iPad/Mac)
Section titled “2.6 Multi-window and Handoff (iPad/Mac)”- 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.
2.7 Android mapping (recommended)
Section titled “2.7 Android mapping (recommended)”| iOS | Android (Compose, M3 Expressive) |
|---|---|
| Sidebar: 2 top rows + Library ×4 + Discover ×5 + Start flying | Compact: 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 map | To 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 map | SupportingPaneScaffold: main = map, supporting = detail panel, 250–480 dp. Compact uses a bottom sheet (see §5). |
| Statistics modal / Mac window | Full-screen route (Dialog with usePlatformDefaultWidth=false). On large screens optionally a new task via FLAG_ACTIVITY_LAUNCH_ADJACENT. |
| Open in New Window | FLAG_ACTIVITY_NEW_DOCUMENT or FLAG_ACTIVITY_MULTIPLE_TASK, offered only on large screens / desktop windowing. |
| Persisted destination, list, expanded sections | DataStore keys detailDestination, list, libraryExpanded, discoverExpanded. |
2.8 Keyboard shortcuts
Section titled “2.8 Keyboard shortcuts”Map these to Android onKeyEvent / KeyboardShortcutGroup for ChromeOS, tablets and desktop windowing.
| Keys | Command |
|---|---|
| ⌥⌘1–9 | Explore map, Flights, Tasks, Airspaces, Waypoints, Soaring Spot, WeGlide, Charron.line, SkyLines |
| ⌘1 / ⌘2 | View as Gallery / View as List |
| ⌘+ ⌘− ⌘0 | Gallery zoom in / zoom out / actual size |
| ⌘F | Find |
| Return or ⌘↓ | Open selection (“Open flight” / “Open %u flights”) |
| ⌘A | Select all (enters select mode) |
| ⌘⌫ | Delete selection (asks first) |
| ⌘O | Add Flights… (menu.button.importflights) |
| ⌥⌘N | New window |
| ⌘, | Settings |
Home-screen quick actions are “Create task”, “Show the map” and “Start/Stop flying”. On Android these become static/dynamic ShortcutManager shortcuts.
3. Flight list
Section titled “3. Flight list”3.1 Data source
Section titled “3.1 Data source”Shell/Sidebar/List/ListViewModel.swift, Configuration/ListConfiguration+Data.swift, ListViewModel+Filter.swift, rows in Packages/FlightScreens/.../FlightList/.
- Core Data
CoreFlightthrough anNSFetchedResultsController(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.
- Logbook:
- Header subtitle: the count of rows shown,
flight.label.subtitle“No flights” / “%u flight” / “%u flights” (plural). - Android: Room
Flow<List<FlightRow>>viaPagingDataor plain lists. Unit changes re-map rows without re-querying.
3.2 Grouping and sort
Section titled “3.2 Grouping and sort”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.
- The header is a row in
- 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):
| List | Orders (menu order) | Default |
|---|---|---|
| Logbook | Date (calendar, key takeoffAt) · Duration (clock, duration) · Distance (ruler, optimizedDistance) · Speed (speedometer, optimizedAverageSpeed) | Date |
| Recents | Last Opened (clock.arrow.circlepath, lastOpenedAt) · Date · Duration · Distance · Speed | Last 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
DropdownMenuwith twoDropdownMenuGroups (M3 Expressive menu groups) and checkmarked items. Keep it open on click.
3.3 Filter (Logbook only)
Section titled “3.3 Filter (Logbook only)”Files:
Packages/FlightScreens/.../ListFilter/{ListFilterItem,ListFilterView,ListFilterViewModel,ListFilterProviding}.swiftHosting/ListFilter/ListFilterHostingController.swiftModules/App/Sources/Features/Flight/ListFilter/AppListFilterProvider.swift
Entry point and presentation:
- The button is
line.horizontal.3.decreasewith 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.
- 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.
- One tile per distinct
- Criteria (
list.filter.filter.title):
| # | Label (key list.filter.filter.type.*) | Icon | Colour | Predicate |
|---|---|---|---|---|
| 1 | Favorite | heart.fill | Favorite | isFavorite |
| 2 | Recorded | record.circle | brand | isRecordedByVulture |
| 3 | Contest | crown.fill | Contest | isContest |
| 4 | Field landing | car.fill | Car | isFieldLanding |
| 5 | Task Finished | asset Task | Success | isTaskCompleted |
| 6 | Balasted | drop.fill | Water | waterBalast > 0 |
| 7 | Unread | eyeglasses | Infringement | isRead == NO |
| 8 | No comment | bubble.left.and.bubble.right | tertiary | comments empty |
- Logic:
- Countries are OR’d together.
- Criteria are AND’d together.
- The two groups are AND’d.
- Persistence:
Preferences.listFlightFilter, keyscountry.<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:
ModalBottomSheeton compact,DropdownMenu/popup orPopupcard on expanded.- Tiles map to
FilterChips in aFlowRow, orToggleButtons in a 3-column grid. - A filled
IconButtonsignals the active state; M3 ExpressiveIconToggleButton“selected” shape morph also works.
3.4 Search
Section titled “3.4 Search”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.InputFieldin the top bar. On compact, use the dockedAppBarWithSearch(M3 Expressive 1.4). - Diacritic-insensitive matching: normalize to NFD and strip marks in a Room function or in memory.
- M3
3.5 List vs gallery
Section titled “3.5 List vs gallery”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 width | Columns |
|---|---|
| < 460 | 2 |
| 460 to < 500 | 3 |
| ≥ 500 | smallest 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 fromBoxWithConstraints;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.
- Leading thumbnail (
TrackThumbnailTile)- 44 pt, scaling with Dynamic Type. Corner radius = size / 5.5.
- Content, in priority order:
- A muted Mapbox snapshot with the track drawn on it (light or dark variant), fading in over 0.25 s.
- Otherwise the track polyline in the accent colour with a start marker.
- Otherwise the
airplaneplaceholder.
- 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.
- 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.
- Logbook:
- Chevron.
- Pilots in
- 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 ontertiarySystemFill.- Order: distance · duration · speed.
- Distance is the task distance if > 0, otherwise the optimized distance, in whole units.
- Duration is compact:
4h12m,3hor05m. - 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.
- Glider:
- Line 3
- Comment in
footnote, secondary, 1 line.
- Comment in
States:
- Active row:
- Regular width: an accent card (radius 16) with white text.
- Compact: a
systemFillbackground.
- Picked row (select mode): a
systemFillgrey 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.
3.7 Gallery card (GalleryCard.swift, DesignSystem/Components/GalleryTile.swift)
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.
- Line 1: pilots in
3.8 Interactions
Section titled “3.8 Interactions”| Input | Touch | Mac |
|---|---|---|
| Tap | Open the flight | Click selects (Shift extends, ⌘ toggles); double-click within 0.5 s opens |
| Long press | Context menu with preview | Right-click menu |
| Swipe | No 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)
- Distance (
- 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.
3.9 Row context menu (single flight)
Section titled “3.9 Row context menu (single flight)”Built lazily: ListViewController+Menu.swift, Shell/Menus/FlightMenuBuilder.swift.
| # | Group | Items | Conditions |
|---|---|---|---|
| 1 | Window | Open in New Window | iPad and Mac only |
| 2 | Share | Share (flight.button.share): IGC share sheet | Always |
| 2 | Share | Upload flight ▸ (flight.button.upload): SkyLines / WeGlide / Charron | Connected services only; SkyLines is Pro (.uploads) |
| 2 | Share | (DEBUG) Simulate flight | Debug builds |
| 3 | Recents | Save to flight log (flight.button.save, folder.badge.plus) | Recents list only, recent-only flights |
| 3 | Recents | Remove from Recents (recents.button.remove) | Recents list only |
| 4 | Mark | Mark flight ▸ (flight.button.mark): Favorite / Contest / Field landing, each a toggle | Not for recent-only flights |
| 5 | Edit | Edit flight (flight.button.edit): form sheet “Edit flight” | Not for recent-only flights |
| 5 | Edit | Delete flight (flight.button.delete, destructive) | Logbook only. Touch: a submenu [Cancel, Delete flight], which deletes immediately. Mac: an alert. |
3.10 Bar items and overflow
Section titled “3.10 Bar items and overflow”| Window | Top bar | Bottom bar |
|---|---|---|
| Compact | Statistics (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.
- Group 1: Files (
- Overflow menu (in order):
- Zoom Out | Zoom In (gallery, regular width only)
- Sort By ▸
- View as Gallery / List
- Select (shows Done while selecting)
- Clear Recents (Recents only; destructive, no confirmation)
Android:
- Compact uses the M3 Expressive
HorizontalFloatingToolbar(or a dockedFlexibleBottomAppBar) holding filter, search and a FAB-style ”+” that opens a menu. The FAB can be aFloatingActionButtonMenuwith “Files”, “Bluetooth device” and “Wi-Fi” items, which is the M3 Expressive equivalent of the iOS + menu. - Expanded uses
TopAppBaractions. - Use
IconButton+DropdownMenufor the overflow, since the system ellipsis does not exist on Android.
3.11 Select mode and bulk actions
Section titled “3.11 Select mode and bulk actions”- 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.
- Title: “Delete flight?” / “Delete %u flights?” (
- 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 (
TopAppBarwith a count title). - Bottom: a large
Button(M3 Expressive large/XL button size) “Open N flights”. - Delete confirmation:
AlertDialog.
- The top app bar switches to a contextual selection bar (
3.12 Drag and drop
Section titled “3.12 Drag and drop”ListViewController+Drag.swift, +Drop.swift, Shell/DragAndDrop/*
- Drag out:
- Payload: IGC text. File name is the stored
filename, otherwise{compId|registration|pilot}_{ddMMyyyy}.igc, otherwiseFlight.igc. - Copy only.
- In select mode, all picked rows are dragged.
- Private marker type
be.fousa.vulture.library-itemblocks dropping back into the library. - Dropping onto a flight map adds the flight to it.
- Payload: IGC text. File name is the stored
- 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.dragAndDropSourcewith aClipDatacontent URI (FileProvider). - Drop in:
Modifier.dragAndDropTargetfor multi-window / desktop.
- Drag out:
3.13 Empty states
Section titled “3.13 Empty states”ListConfiguration+Properties.swift, SidebarListPlaceholderView.swift (ContentUnavailableView), SidebarListNoticeView.swift
| Situation | Title | Message | Action |
|---|---|---|---|
| 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
Columnwith an icon,titleLarge,bodyMedium, and aFilledTonalButton(the equivalent of the glass capsule.placeholderAction()).
3.14 Statistics screen
Section titled “3.14 Statistics screen”Packages/FlightScreens/.../Statistics/*, Hosting/Statistics/FlightsStatisticsViewController.swift (a UIKit collection view reusing CompareFlightsCollectionViewLayout)
- Entry and presentation:
- The
chart.barbutton on the Logbook. - Pro-gated (
.statistics). - Full-screen modal titled “Statistics” (
flight.statistics.title), with Close at leading.
- The
- 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.fillbadges. - Higher is better, except field landings, where lower is better.
Group (flight.statistics.section.*) | Metric (flight.statistics.data.*) | Aggregation |
|---|---|---|
| Flights | Total flights | count |
| Flights | Duration | sum, whole hours |
| Optimized distance | Distance | sum of optimized distance |
| Optimized distance | Longest distance | max |
| Optimized distance | Average speed | mean |
| Optimized distance | Best average speed | max |
| Task | Total completed | count of isTaskCompleted |
| Task | Task / free flight | completed ÷ count, “%0.0f %” |
| Task | Total distance | sum of task distance |
| Task | Longest distance | max |
| Task | Average speed | mean |
| Task | Best average speed | max |
| Performance | Total competitions | count of isContest |
| Performance | Total fieldlandings | count (lower is better) |
| Performance | Average cruise speed | mean |
| Performance | Best cruise speed | max |
| Performance | Average water balast | mean over ballasted flights only |
| Thermals | Total altitude gained | sum |
| Thermals | Best vario | max |
| Thermals | Total right turns | sum |
| Thermals | Total left turns | sum |
- Averages include flights with zero values, except water ballast.
- Android: a 2-D scroll grid with a frozen metric-label column (
LazyColumnof rows with a sharedhorizontalScrollstate). 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. Getting flights in (import)
Section titled “4. Getting flights in (import)”4.0 Two outcomes, which is the key concept
Section titled “4.0 Two outcomes, which is the key concept”| Outcome | Behaviour | Entry 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 saving | The 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.aipand.cubfiles 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”| iOS | Value | Android |
|---|---|---|
UTImportedTypeDeclarations / CFBundleDocumentTypes | public.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, UIFileSharingEnabled | true; the app never scans Documents (DEBUG -VultureProbeImportDocuments aside) | n/a |
| URL scheme | vulture | <data android:scheme="vulture"/> |
| Associated domain | applinks:vulture.aero | App Links: autoVerify + assetlinks.json on vulture.aero (backend work) |
| Bluetooth usage | “Connect to LXNAV devices.” | BLUETOOTH_SCAN (neverForLocation) + BLUETOOTH_CONNECT runtime permissions |
4.2 Entry points in detail
Section titled “4.2 Entry points in detail”1. Logbook ”+” › Files (outcome A)
- Files:
ListViewController+Toolbar.swift(flightImportElements)Shell/Split/SplitViewController+Import.swift- Pipeline:
Shell/Split/SplitViewModel.swiftimport(fileURLs:)
- Picker:
- Document picker restricted to
public.igc. - Multi-select, file extensions shown.
- Document picker restricted to
- 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:
FlightDecoder(fileURL:)with sport profilePreferences.parsingSportProfile.decode(), which parses and enriches.createFlight(isRead: false).
- Errors are collected as
VultureError.importFailedand presented after the last file.
- Each file is routed by its lower-cased extension:
- No progress UI.
- Android:
rememberLauncherForActivityResult(OpenMultipleDocuments())witharrayOf("*/*"), then filter on.igc, because MIME types are unreliable.- Run the import in a
WorkManagerjob or a coroutine onDispatchers.IO. Optional deviation: show aDecided: no, silent as on iOS (decisions §6).Snackbar(“3 flights imported”).
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. DroppedFileLoadercopies intotmp/Dropped/<UUID>/:- Name: the suggested name plus extension, or
Dropped-<n>.<ext>. - Clashing names get an
<index>-prefix.
- Name: the suggested name plus extension, or
- 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.dragAndDropTargetwithDragAndDropTarget.onDropreadingClipDataURIs.- Call
requestDragAndDropPermissionsfor 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.
- Title “Select a device” (
- Protocol (
Packages/Bluetooth):- LXNAV
$PLXVCNMEA, written without response to the first such characteristic, with notify enabled. - 30 s timeout per sentence.
- LXNAV
- Flow (
Hosting/Bluetooth/Send/BluetoothSendDataViewModel.swift):- Status “Reading flights…” (
bluetooth.label.fetch.flights).- Sends
LOGBOOKSIZE,R, thenLOGBOOK,R,1,<n+1>. - Builds a list of
BluetoothFlight(filename, date/time, size), newest first.
- Sends
- 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).
- Uses
- 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.
- 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.
- Decode and
- 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.
- Alert: “Something went wrong” (
- Status “Reading flights…” (
- Android:
BluetoothLeScanner+BluetoothGatt(or Nordic Android-BLE-Library).- Run the transfer in a foreground service of type
connectedDevicewith 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 ©.”
- Title “Send to Wi-Fi device” (
- Protocol (
Packages/Flarm):- HTTP to
http://10.10.10.10/api. - Poll
GET infoevery 1 s until adeviceIdarrives. The footer then shows the FLARM logo + “Connected to:” + the id. - Status “Reading flights…”:
GET flarm/igcreturns[{index, filename, timestamp}]. - Sheet “Select a flight” (
wifi.label.flights). - Status “Downloading flight…”:
GET flarm/igc/<index>. - Decode and save, then the “Complete” alert, which unwinds.
- HTTP to
- The spinner is always indeterminate.
- Android:
- Needs
network_security_configallowing cleartext to10.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.
- Needs
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.
| Path | Action |
|---|---|
/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
/Chromeand could link tohttps://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.xmlcapabilities.
- Android: App Actions /
- Handoff.
- In-app recording (Pro
.recording):Packages/FlightRecordingturns 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.
4.3 IGC decode and what is stored
Section titled “4.3 IGC decode and what is stored”Decoder: Packages/Flights/.../Decorder/FlightDecoder.swift, Models/Flight.swift.
Records parsed:
AH: DTE, PLT, CM2, GTY, GID, FTY, GPS, PRS, MOP, CID, CCLE(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.idis 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,isReadtakeoffAt(date + take-off fix, with day rollover)pilotName,passengerName,gliderType,gliderRegistration,competitionIdentification,competitionClassisTaskCompleted,taskDeclaredDistance,taskCompletedDistance,taskAverageSpeedoptimizedDistance,optimizedAverageSpeed,fastestAverageSpeed(best 2 h)duration,altitudeGained,averageCruisingSpeed,bestThermalVario,left/rightThermalCounttakeoffCode,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:
| WeGlide | SkyLines | |
|---|---|---|
| API | https://api.weglide.org/v1/ (cert pinning) | https://skylines.aero/api |
| Auth | X-API-Key from the Keychain | Basic auth + user id |
| Scopes offered | Your flights only | Signed in: Your / Latest / Daily. Otherwise: Latest / Daily. |
| Download | flightdetail/<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).
- “Not connected” (
- 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.
- “Select” (
States, in priority order:
-
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).
- “Connect WeGlide” (
-
Full-screen error (
ErrorStateView): headline, sentence and SF symbol perFailureReason, plus “Try again”.Headline Symbol Sentence You’re Offline wifi.slashThis needs an internet connection, and there is none at the moment. No Answer icloud.slashThe service could not be reached, or took too long to answer. Service Unavailable exclamationmark.icloudThe service ran into a problem of its own. That usually clears up within a few minutes. Access Refused lockThe service did not let the app in. If you use an account with it, check in Settings that it is still connected. Not Found questionmark.circleThe service no longer has what this screen shows. Unexpected Answer exclamationmark.triangleThe service answered with something the app could not read. If this keeps happening, the app may need an update. Something Went Wrong exclamationmark.triangleThis could not be loaded. Keys:
error.state.*. A failure clears the rows and ends the select session. -
Loading: a full-screen spinner (
LoadingOverlay), with a VoiceOver announcement when done. -
Empty: “There are no flights available from WeGlide.” / “…from SkyLines.”
-
No search results:
ContentUnavailableView.search. -
The list.
List behaviour:
- Pull-to-refresh.
- Infinite paging when the last row appears.
- Row layout (
ImportFlightRow):- [
SelectionTickin 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
systemFillbackground.
- [
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
DatePickerpopover 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:
PullToRefreshBoxwith the M3 ExpressiveLoadingIndicator(the morphing-shape indicator) as the refresh and first-load indicator.- Paging 3
LazyPagingItems. DatePickerDialog.- A
HorizontalFloatingToolbarbottom bar with an embedded search field, and a FAB-styled “Open N flights”.
4.5 Duplicates
Section titled “4.5 Duplicates”- Logbook save (
Persistence/.../CoreFlight+CoreData.swift):- If a flight exists with identical raw IGC text, the existing record is returned silently and
isReadis 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.
- If a flight exists with identical raw IGC text, the existing record is returned silently and
- Background deduplication (
DataManager.deduplicate, persistent history / CloudKit): same filename + same content keeps the oldest record. - On the map: the same
Flight.idtriggers “This flight is already shown on the map.” (error.flight.label.duplicate).
4.6 Progress, errors and success
Section titled “4.6 Progress, errors and success”| Path | Progress | Success |
|---|---|---|
| Files / drop → logbook | none | nothing 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. |
| Bluetooth | Determinate bar + ”%@ remaining” + NMEA footer + notifications | “Complete” alert |
| FLARM | Indeterminate spinner + status + connected device | “Complete” alert |
| Services list | Full-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
ErrorListViewwith Close, a big redexclamationmark.triangle.filland “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:
AlertDialogfor one error; aModalBottomSheetor 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 || isSubscribedallowsFlights(count) = count <= 1 || isUnlocked(.severalFlights)
Gated:
- Every external entry waits for
isAppOpen(hard paywall when the free tier is off). .severalFlightscovers 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:
- “Reading current declaration…” (
DECL,R,1,6). - If the pilot or glider is missing, the alert “Missing information” / “Do you want to add your pilot information?” appears.
- “Transferring task declaration…” writes
DECL,Wwith 6 pilot lines + the NANO-encoded task. - A determinate bar.
- “Complete” / “The task declaration is successfully transferred.”
FLARM: FlarmConfigRequest or FlarmTaskRequest.
4.9 Quirks to decide on
Section titled “4.9 Quirks to decide on”- 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.
5.0 Key surprises (read first)
Section titled “5.0 Key surprises (read first)”- 3D does not exist.
Docs/3d-map-investigation.mdis “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. - 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.
- 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.
- 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. - The flight map has no legend.
LegendDockandLegendVieware only used byTaskMapViewandInFlightMapView(weather legends). - 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.
- 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. - “OLC/WeGlide scoring legs” is really the app’s own distance optimisation (credited “Koen Peeters”), plus the declared-task attempt. See §6.
5.1 Screen layout
Section titled “5.1 Screen layout”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 = 8from 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.
- Margin:
- Panel contents, top to bottom:
- Dragger image: tertiary tint, 3 pt from the top.
- Flights strip: 10 pt from the top. See §1.4.
- Barogram container: 8 pt side insets, top corners rounded 16.
- 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) orcircle(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.
- Leg: symbol
- Show-graph button, bottom-trailing. A round glass
MapButton, symboldock.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).
- Wind pill, bottom-leading. 16 pt inset (8 pt on regular width). Contents:
- 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.
- Add:
- 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.
- Two stacked glass targets:
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.subtitleplural), with the first flight’s date as subtitle.
Right bar items, listed from the right edge leftward:
| Item | SF symbol | Shown when | A11y / title |
|---|---|---|---|
| Info (flight detail) | info (Mac info.circle) | content available (a flight is selected) | “Open detail” (accessibility.button.menu.detail) |
| Compare | arrow.left.arrow.right | more than one flight | “Compare” (flight.button.compareflights) |
| Debrief | text.bubble, titled | regular width and a flight is present | “Debrief” (flight.button.debrief) |
| Ranking | list.number, titled | regular width and the ranking is available (§4.4) | “Ranking” (flight.button.ranking) |
| Satellite clouds | icloud.and.arrow.down (tiles not downloaded) / cloud (off) / cloud.fill (on); while downloading, a progress ring drawn around a small cloud | always; 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):
- “Go to” submenu (
menu.title.goto, symbollocation). 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.
- “Glider” (
- 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.
- “Align start” (
- “Debrief” (
text.bubble): compact width only. On regular width it is a bar button. - “Ranking” (
list.number): compact width only, when available. - “Flight Coach” (
sparkles): disabled by a hard flag (isFlightCoachEnabled = false). Ignore. - 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.
- “Save to flight log” (
- 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.
- “IGC File” (
- “Upload flight” submenu (
arrow.up): SkyLines / WeGlide / Charron, only for connected services.
- “Share” submenu (
- “Terrain” (
map): reloads the terrain elevation. HUD “Loading terrain…”. - “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.
5.1.3 iPhone vs iPad differences
Section titled “5.1.3 iPhone vs iPad differences”| iPhone portrait | iPhone landscape (compact-V) | iPad / regular width | |
|---|---|---|---|
| Nav title | hidden | shown | shown |
| Debrief / Ranking | overflow menu | overflow menu | bar buttons (with title) |
| Panel height | 40% | 45% | 40% |
| Control row (play / zoom / unit) | shown | hidden | shown |
| Wind and phase pills | shown | hidden | shown (wind inset 8) |
| Flights strip height | 70 (multi) / 30 (single) | 35 (multi) / 30 (single) | 70 / 30 |
| Flight card value rows | altitude, vario, speed + date | altitude only, no date | all |
| Graph grid interval | “large” | “small” (isSmall = isPhoneInLandscape) | large |
| Reset zoom button | icon only | – | icon + “Reset zoom” |
| Companion (Debrief / Ranking) | card | card | popover with map passthrough |
| Compare screen | modal sheet (automatic) | – | full screen (popover delegate: regular width becomes fullScreen) |
| Compare columns visible | 2 | 2 | up 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 %@%@”.
5.2 Barogram (“graph”)
Section titled “5.2 Barogram (“graph”)”Files:
Panel/Graph/PanelGraphView.swift,+Gestures,+AccessibilityGraphDataSource.swiftViews/GraphFlightDataView.swift,GraphBitmapRenderer.swift,GraphHandleView.swift,GraphHandleMetadataView.swift,GraphMetadataView.swift,GraphValueView.swift
5.2.1 Series and the unit menu
Section titled “5.2.1 Series and the unit menu”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):
- Inline group: “Average task speed” (only if the selected flight attempted a task), then “Average speed”.
- Plain items: “Wind speed”, “Vertical speed”, “Groundspeed”.
- Submenu “Altitude” (
mountain.2): first any altitude-category extension the IGC carries (“Total energy”), then “GPS altitude”, then “Pressure altitude”. - 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”
- Speed (
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):
| Unit | Symbol |
|---|---|
| GPS altitude | rotate.3d |
| Pressure altitude | barometer |
| Groundspeed, Airspeed | hare |
| Vertical speed, Total energy | tornado |
| Wind speed | wind |
| Average speed, Average task speed | speedometer |
| Engine RPM | arrow.triangle.2.circlepath |
| Temperatures | thermometer |
| Battery left | battery.50 |
| Electrical voltage | bolt.batteryblock |
| Electrical current | bolt |
| Fuel level | fuelpump |
| Fuel flow | alternatingcurrent |
| G-force, Vertical acceleration | dot.arrowtriangles.up.right.down.left.circle |
| Flaps | trapezoid.and.line.vertical |
| MOP | tropicalstorm |
| Air movement | arrow.up |
| Pitch angle | circle.and.line.horizontal |
| Roll angle | line.diagonal |
| Heart rate | heart |
| Blood oxygen | drop |
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):
| Unit | Format |
|---|---|
| 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%%" |
| Flaps | the 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):
| Unit | Large | Small |
|---|---|---|
| Altitude, m | 500 | 1000 |
| Altitude, ft | 1500 | 3000 |
| Speeds | 50 | 100 |
| Vario, m/s | 5 | 10 |
| Vario, ft/s | 1000 | 500 |
| Wind | 10 | 20 |
| G-force | 1 | 2 |
| Heart rate | 40 | 10 |
| Others | see 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
#C3AC93light /#644E35dark; 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
tertiaryLabelfill at alpha 0.3, scaled to the GPS altitude range. - Thermal bands: full-height vertical bands from thermal entry to exit. Colour “Thermal”
#488165light /#69AB8Bdark, 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
#F6B91Eat 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.
- Green (Success
- Optimised track (requires “Optimized track” not None):
- Brand orange
#DF774Abadges “S”, “1”…, “F”, with dashed lines [4,3]. - A badge is shifted one row up if it would overlap a green one.
- Brand orange
- 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.
- A label “AAT: h:mm” in
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
- each flight’s
- The map camera does not follow while scrubbing, except via the “Pin current position” setting (§3.6).
Keyboard (hardware keyboard / Mac):
| Key | Action | Strings |
|---|---|---|
| Space | play / 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” |
| ⌥⌘S | Compare | |
| ⌥⌘R | Ranking | |
| ⌥⌘F | Flight | |
| ⇧⌥⌘F | Show all flights | |
| ⌥⌘G | Glider |
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)”5.3.1 Tracks
Section titled “5.3.1 Tracks”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 #0000FFThe 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.
5.3.3 Thermals, events, photos
Section titled “5.3.3 Thermals, events, photos”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#F6B91Eto 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
#5F5F5Fstroke; 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.
5.3.4 Highlights
Section titled “5.3.4 Highlights”- Leg highlight: a 1.6 to 3 line in
#D5A359with 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.
5.3.6 Taps, gestures, camera
Section titled “5.3.6 Taps, gestures, camera”Map tap:
- Photos are checked first, with a ±22 pt touch rect. A hit opens a full-screen dark photo pager.
- 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:
- Without Pro, the paywall appears (
.skySight). - Without a SkySight login, the sign-in screen appears; after sign-in the tap continues.
- If tiles are missing, they are downloaded. The progress ring fills (accessibility value “NN%”) and the overlay turns on when done.
- 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.
5.3.8 Map-related settings (live-updating, in the Settings screens, not on the map)
Section titled “5.3.8 Map-related settings (live-updating, in the Settings screens, not on the map)”| Setting (key) | Default | Effect |
|---|---|---|
“Show colored track” (map.settings.coloredtrack) | on | altitude gradient vs plain darkened colour (§3.1) |
| “Pin current position” | off | camera follow (§3.6) |
| “Show wind indicator” | on | wind pill |
| “Show your photos” | off | photo markers |
| “Show thermals” | on | thermal circles and graph bands |
| “Show thermal attempts” | on | attempt circles and graph bands |
“Show flight events” (map.settings.optimization.altitudegained) | on | event dots and pills on altitude graphs |
| “Optimized track” | Free 5 turnpoints | picker: None / Straight / Out & return / Triangle / Free 3 tp / Free 5 tp; drives the map line and the orange graph badges |
| “Show task” | on | task overlay |
| “Show optimized task” | on | green task-attempt line and graph badges |
| “Show minimum AAT duration” | off | AAT 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”. BackgroundquaternaryLabel, 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 × multiplierto an accumulator. Whole seconds are applied astime += 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 − timeOffsetfor 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.
5.4.3 Time alignment of several flights
Section titled “5.4.3 Time alignment of several flights”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
.cupfile 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):
| Situation | Phase | Values |
|---|---|---|
| t < start, or no start | notStarted | – |
| t ≥ finish | finished | distance = task distance; speed = task distance ÷ task time |
| t ≥ last sample without finishing | landedOut | distance frozen; no speed |
| otherwise | racing | distance = sample at t; speed = distance ÷ max(elapsed, 15 min) |
Order (RaceRanking.standings):
- Finished and racing flights together, by speed, descending.
- Then landed-out flights, by distance, descending.
- Then not-started flights, unplaced.
- 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).
5.5 Comparing multiple flights
Section titled “5.5 Comparing multiple flights”5.5.1 Getting several flights on the map
Section titled “5.5.1 Getting several flights on the map”- 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.
- Without Pro (
- 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 | #4A6FA0 | 6 | #A64F66 | 11 | #AA9842 | 16 | #469378 |
| 2 | #5B5B99 | 7 | #A65252 | 12 | #A2A148 | 17 | #46938A |
| 3 | #6E528F | 8 | #AA6548 | 13 | #88A14C | 18 | #469393 |
| 4 | #884C89 | 9 | #AA7648 | 14 | #689A50 | 19 | #46869E |
| 5 | #9C4880 | 10 | #AA8842 | 15 | #50935E | 20 | #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:
systemBackgroundin light mode,secondarySystemBackgroundin 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.fillicon 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”.
- The pinned column shows a
- 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 (
#587899light /#5C7EA1dark). - ▼ “Δ” in “worse” colour
#C2400D(rgb 0.76, 0.25, 0.05). - ”=” in
secondaryLabelwhen equal.
- ▲ “+Δ” in “better” colour secondaryTint (
- Δ 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.fillin gold#B68B1Clight /#D9A621dark2.circle.fillin silver#8D9399/#999EA33.circle.fillin 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.
- Score: a half-ring gauge (64×42, 4 pt stroke, track
- “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:
Score Band Colour under 400 Rough red under 550 Fair orange under 700 Solid yellow under 850 Strong green otherwise Outstanding tintSecondary #5C7EA1A 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):
- 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.
- 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”
- Section “Legs”:
- An optional start row (task start waypoint name and time).
- One row per leg:
- Status icon:
checkmark.circle.fillgreen,xmark.circle.fillorange, or “N.circle.fill” grey. - Title: the turnpoint name, or the time for free optimisations.
- Caption: “time · speed · vario · ↻ thermal% · ⇄ detour”, or
location.northwith the declared bearing for an unflown leg. - Trailing: the leg distance in semibold.
- Status icon:
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.
5.7 Loading, errors, empty states
Section titled “5.7 Loading, errors, empty states”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.
5.8 Key file index
Section titled “5.8 Key file index”Modules/App/Sources/Map/Flight/FlightMapViewController.swift: bar, menus, compare, ranking, satellite, drop, commandsModules/App/Sources/Map/Flight/FlightMapViewModel.swift: loading, colours, playback, time, alignment, task override, thermals navigationModules/App/Sources/Map/Flight/FlightMapViewModel+GraphMenu.swift,FlightMapViewModel+Ranking.swiftModules/App/Sources/Map/Flight/FlightMapView.swift: panel card, pills, drop overlayModules/App/Sources/Map/Flight/Panel/PanelContentViewController*.swift: control row, play menu, unit menuModules/App/Sources/Map/Flight/Panel/Graph/**: barogramModules/App/Sources/Map/Flight/Panel/Flights/**: flight cardsModules/App/Sources/Map/Flight/DataSource/**,Map/Rendering/Layers/**,Map/Rendering/Views/GliderAnnotationView.swiftModules/App/Sources/Map/Extensions/Flight+Polylines.swift(altitude gradient),UIColor+Theme.swift,Thermal+Feature.swiftPackages/Preferences/Sources/Preferences/Models/{PlaySpeed,GraphDataUnit,TrackOptimization,MapStyle}.swift; defaults inModules/App/Sources/Application/AppDelegate.swift(prepareFlightSettings)Packages/Flights/Sources/Flights/Models/{RaceProgress,RaceRanking,LeechingAnalysis*}.swiftPackages/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” containerDocs/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 element | Android / Compose |
|---|---|
| Full-screen Mapbox map, list chrome hidden | Mapbox 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.7 | A 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 submenus | Play 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 panel | Small 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 passthrough | Phone: 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 CADisplayLink | withFrameNanos 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 list | LazyColumn with Modifier.animateItem() to animate reorders. A 32 dp circle badge in the flight colour. Finishers get a checkered-flag icon. |
| Optimisation detail mini map | A 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, …).
6.0 How to read this section
Section titled “6.0 How to read this section”- Read-only. Nothing in
/Users/fousa/Projects/vulture-ioswas 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.
- [DS] =
- Package moves. The design docs say
Packages/Scenes/...andModules/App/Sources/Modules/Detail/. That code now lives inPackages/FlightScreens/Sources/FlightScreens/andModules/App/Sources/Features/Flight/Detail/.
6.0b Formatting rules used everywhere
Section titled “6.0b Formatting rules used everywhere”The formatters live in Packages/Preferences/.../Units/Formatter/UnitFormatter.swift and its extensions.
6.0b.1 Numbers and units
Section titled “6.0b.1 Numbers and units”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, %.
6.0b.2 Specific formats
Section titled “6.0b.2 Specific formats”| Value | Format | Example |
|---|---|---|
Durations (timeValue) | HH:MM:SS (%.2d:%.2d:%.2d). Negative values or values over 24 h wrap around one day. | 04:32:11 |
veryShortTimeValue | Hours, minutes and seconds, with zero parts dropped | 4m 12s, 1h 3m |
percentageValue | %0.2f %% | 41.23 % |
degreesString / bearingString | %i° | 25° |
| Glide ratio | n:1 | 42:1 |
Water (shortWaterValue) | "<n> liter", hard-coded English | 120 liter |
Count (stringValue on Double) | %0.2f | — |
mediumDayString | DateFormatter medium date | 25 Jun 2025 |
fullDayString | .full date style | Wednesday 25 June 2025 |
6.0b.3 Rows without a value are hidden
Section titled “6.0b.3 Rows without a value are hidden”- A
DetailInfoItemreturnsnilwhen 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”6.1.1 Entry point
Section titled “6.1.1 Entry point”The entry point is the flight map’s info bar button.
| Platform | Glyph |
|---|---|
| iPhone / iPad | info |
| Mac | info.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”.
- On the Mac its title is “Show Detail” / “Hide 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 newDetailPopupController(viewModel: DetailPopupViewModel(viewModel: selectedFlightViewModel, preferences: .shared)).
6.1.2 Presentation by platform
Section titled “6.1.2 Presentation by platform”| Platform | Container | Notes |
|---|---|---|
| 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.
6.1.3 Summary page (iPhone and iPad root)
Section titled “6.1.3 Summary page (iPhone and iPad root)”Code:
Packages/FlightScreens/.../FlightDetail/Summary/DetailSummaryView.swiftDetailSummaryViewModel.swiftDetailSummaryRow.swiftDetailSummaryRowView.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, titledflight.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, otherwiseTrackSimplifierrun on the fixes.
- A rounded rect, radius 16, filled with
- heroTitle (title2 semibold, max 2 lines). The first non-empty of:
- the declared task name
- the record’s takeoff location
- the glider type
- 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.
| Tile | Key → English | Value |
|---|---|---|
| Distance | flight.statistics.data.optimized.distance → “Distance” | judgedDistance: best task attempt distance if > 0, otherwise free 5-turnpoint distance if > 0 |
| Duration | flight.statistics.data.duration → “Duration” | judgedDuration = duration (takeoff→landing) ?? glidingBlockDuration, as HH:MM:SS |
| Average speed | flight.info.label.cruise.speed → “Average speed” | judgedAverageSpeed: task attempt speed when the task has a distance, otherwise free-distance speed; must be > 0 |
| Height gain | flight.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.
| # | Page | Title (key) | Caption | Headline / detail | Icon | Tint | Shown when |
|---|---|---|---|---|---|---|---|
| 1 | declaredTask | “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 > 0 | point.topleft.down.to.point.bottomright.curvepath | success green if completed, otherwise accent | flight.task and flight.bestTaskAttempt exist |
| 2 | performance | “Performance” (flight.tab.label.performance) | name of the furthest optimisation (e.g. “Free 5 turnpoint distance”) | its distance / its average speed if > 0 | rosette | accent (brand orange) | any optimisation with distance > 0. Candidates: Free 5 TP, Free 3 TP, Triangle, Out & return, Straight |
| 3 | thermals | “Thermals” (flight.tab.label.thermals) | “%u thermals” (flight.summary.caption.thermals) | average thermal vario / thermal percentage | tornado | thermal green (asset “Thermal”) | thermal count > 0 |
| 4 | phases | “Phases” (flight.tab.label.phases) | “%u cruises” (flight.summary.caption.cruises) | total phase count | list.bullet | secondary blue (#587899 light / #5C7EA1 dark) | phases not empty |
| 5 | flight | “Flight” (flight.tab.label.flight) | flightDuration (HH:MM:SS) | — | airplane | neutral grey | always |
| 6 | infringements | “Airspace” (airspace.label.title) | “%u Infringements” (flight.label.airspace.crossing; plural “1 Infringement”, zero “No infringements”) | — | exclamationmark.triangle.fill | warning red (infringement) | live airspace scan reports more than 0 crossings |
| 7 | info | “Info” (flight.tab.label.info) | pilot name (record, otherwise IGC) | — | info.circle | neutral | always |
- 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).
- declaredTask → the Optimization detail page (§2.6) for
- Navigation stack: each page is a
DetailTabPageControllerpushed 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
commentsis 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.
6.1.4 Page header (every pushed page)
Section titled “6.1.4 Page header (every pushed page)”- 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:
- A round glass report button (
exclamationmark.triangle, 36 pt) at top-left, 8 pt from the top. - 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.
- The flight name (
- A
UISegmentedControlof 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”.
- Segment accessibility names: “Task” (
- 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 (
DetailSectionTitledraws 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.
- Plain list on a clear background, with no section headers (
6.1.6 Action stream (DetailAction)
Section titled “6.1.6 Action stream (DetailAction)”Code: Packages/FlightScreens/.../Tabs/DetailTabConvertible.swift.
Actions the map acts on (FlightMapViewController.subscribe(controller:)):
| Action | Map 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.
6.2 Each page in detail
Section titled “6.2 Each page in detail”Common page layout (DetailTabContentView.swift):
- A
Listwith oneSectionperDetailContentSection, with a header (DetailSection.headerTitle) and an optional footer.
Row types (DetailItemRow.swift):
| Type | Layout | Tappable? |
|---|---|---|
| Information | One 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 |
| Phase | See §2.4 | Yes |
| Optimization | Title (caption, secondary); distance (body) + speedometer + speed (footnote); optional arrow.triangle.swap + detour (footnote, secondary) | Yes. Has a chevron if isDetailAvailable. |
| Graph | See below | No |
| Metrics grid | Same as §1.3 | No |
| Turnpoint | See §2.5 | Yes |
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 header | Row (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) | Graph | Only 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):
| Row | Value | Action |
|---|---|---|
“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) | same | same |
“Maximum distance out” (flight.label.optimization.maxout) | distance | Tap → .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).
| Order | Row | Key |
|---|---|---|
| 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):
| Left | Right |
|---|---|
“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):
| Left | Right |
|---|---|
“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.
| Order | Option | Key | Icon | Shows |
|---|---|---|---|---|
| 1 | “All phases” | selection.button.phases | list.dash | all |
| 2 | “Events” | selection.button.event | smallcircle.fill.circle | event fixes only |
| 3 | “Engine usage” | selection.button.engine | bolt.fill | engine runs |
| 4 | “Thermalling” | selection.button.thermal | tornado | thermals |
| 5 | “Cruising” | selection.button.cruise | arrow.right | cruise 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 isHH:MM:SS). - A trailing value (semibold, tinted) with a detail line below it.
| Kind | Title | Caption | Value / detail | Icon | Tint |
|---|---|---|---|---|---|
| Event | event name (table below) | takeoff/landing: “alt MSL”; others: “alt − elevation AGL” | — | per event | grey |
| 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 tornado | thermal green |
| Cruise | “Cruise” (flight.label.phase.cruise) | average speed | distance / “n:1” glide ratio | arrow.forward | secondary blue |
| Engine | “Engine run” (flight.label.event.enginerun) | duration HH:MM:SS | — | bolt.fill | grey |
Event icons:
| Event | Icon |
|---|---|
| takeoff | play |
| release | scribble |
| landing | flag |
| low point | arrow.down.to.line |
| high point | arrow.up.to.line |
| furthest point | binoculars |
| anything else | smallcircle.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):
| Phase | Action |
|---|---|
| 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:
TaskMapViewControlleras 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)
- “Declared distance” (
- Only when a flight is attached:
- Info row “Status” (
flight.label.optimization.task.status), coloured:checkmark.circle.fill+ “Finished” in greenxmark.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.
- Info row “Status” (
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.
- Type caption: “Start” / “Turnpoint” / “Finish” (
- 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:- “Go to turnpoint” (
task.menu.center.turnpoint,mappin.and.ellipse). Centres the map and dismisses the popup. - “Insert turnpoint” with subtitle “Before current turnpoint” (
text.insert), and “Insert turnpoint” with subtitle “After current turnpoint” (text.append). - “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.”
- “Go to turnpoint” (
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:- “Save task” (
task.edit.share.button.export.save,square.and.arrow.down). Only when a save callback exists. - “Export to CUP” (
doc) - “Export to LXNAV Nano”
- “Export to FLARM” (
dot.radiowaves.right)
Each export writes the file to the temp directory and opens the share sheet.
- “Save task” (
-
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:
| Order | Label (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):Part Content Icon checkmark.circle.fillgreen = leg reached;xmark.circle.fillbrand = not reached;N.circle.fillgrey = no taskTitle waypoint 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)Trailing distance, 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).
6.2.7 Airspace infringements page
Section titled “6.2.7 Airspace infringements page”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:
.highlightInfringementand 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”.
| Section | Rows |
|---|---|
“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.
6.2.9 Sport-specific behaviour
Section titled “6.2.9 Sport-specific behaviour”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:
| Element | Appears when |
|---|---|
| Winch / tow / release / takeoff performance | release detected (glider) |
| Engine section | ENL or MOP present |
| Flaps graph | the flaps extension is present |
| Airspeed | TAS present |
| Water ballast | logbook record |
The only sport-dependent display is the Speed component of the flight rating (§7).
6.3 Thermal detail screen
Section titled “6.3 Thermal detail screen”Code: Packages/FlightScreens/.../ThermalDetail/*, Modules/App/Sources/Features/Flight/Thermal/ThermalDetailViewController.swift, Map/Flight/Thermal/ThermalController.swift.
6.3.1 Opening it
Section titled “6.3.1 Opening it”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 thermals | show attempts | Paged set |
|---|---|---|
| on | on | all thermals |
| on | off | real thermals only |
| off | on | attempts only |
| off | off | nil |
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.
6.3.2 Navigation bar
Section titled “6.3.2 Navigation bar”- 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.
6.3.3 Layout
Section titled “6.3.3 Layout”- Segmented control:
- “Climb” (
thermal.chart.climb, the default) - “Centering” (
thermal.chart.centering) - “3D” (
thermal.chart.helix)
- “Climb” (
- 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.
6.3.4 Data grid
Section titled “6.3.4 Data grid”A glass card, radius 12, 4 rows × 2 columns, with hairline separators. Each cell is a value (body) over a caption title.
| Row | Left | Right |
|---|---|---|
| 1 | vario, in headline / “Average vario” (thermal.label.average) | turn symbol (arrow.counterclockwise / arrow.clockwise / circle) / “Left turn” · “Right turn” · “Mixed turn”. Accessibility “Direction”. |
| 2 | radius × 2 in the altitude unit / “Diameter” | “%i°” / “Turn rate” |
| 3 | average airspeed / “Average airspeed” | “gained / lost” / “Height gained / lost” |
| 4 | HH:MM:SS / “Duration” | drift / “Drift” |
6.3.5 Climb ramp (absolute, in m/s)
Section titled “6.3.5 Climb ramp (absolute, in m/s)”| Rate | Colour |
|---|---|
| < 0 | Sink #F61D24 |
| 0 | Bad #F6B91E |
| 2 | Average #F6EE1E |
| ≥ 4 | Good #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.
6.3.6 Climb tab (profile chart)
Section titled “6.3.6 Climb tab (profile chart)”Two stacked plots. Each has a tappable dotted-underline header that opens an info popover.
| Header | Popover 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).
6.3.7 Centering tab (plan chart)
Section titled “6.3.7 Centering tab (plan chart)”Geometry:
- Project to a flat plane.
- Fit wind as a least-squares linear drift against time.
- 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:
| State | Caption |
|---|---|
| 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 |
6.3.8 3D tab (helix)
Section titled “6.3.8 3D tab (helix)”- 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$@”.
6.4 Edit flight metadata
Section titled “6.4 Edit flight metadata”Code:
Packages/FlightScreens/.../EditFlight/{EditFlightView,EditFlightViewModel,EditFlightStoring}.swiftModules/App/Sources/Features/Flight/EditFlight/{CoreFlightEditStore,UIViewController+EditFlight}.swift
6.4.1 Entry points
Section titled “6.4.1 Entry points”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.
6.4.2 Presentation
Section titled “6.4.2 Presentation”- A form sheet (
modalPresentationStyle = .formSheet) inside a navigation controller. - Title: “Edit flight” (
flight.edit.label.placeholder[App]). - Confirmation button: “Done” (
common.button.done).
6.4.3 Form, in order
Section titled “6.4.3 Form, in order”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:
dropicon (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:
- Decode the IGC on a background queue.
- Write pilot, passenger, type, registration, competition ID and class back to the store.
- Reload those six fields.
Water, toggles and remarks are untouched.
6.4.4 Validation and saving
Section titled “6.4.4 Validation and saving”- 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.
6.4.5 Fields not on this form
Section titled “6.4.5 Fields not on this form”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.
6.4.6 Mark menu
Section titled “6.4.6 Mark menu”“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.
6.5 Export, share and upload
Section titled “6.5 Export, share and upload”Code is mainly in:
Shell/Menus/FlightMenuBuilder.swiftShell/Sidebar/List/Configuration/ListViewController+Menu.swiftListViewModel+Share.swiftMap/Flight/FlightMapViewController.swiftMap/Flight/Flight/FlightViewModel.swiftSupport/Upload/**
6.5.1 Export formats
Section titled “6.5.1 Export formats”- 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.
- The stored IGC text, unchanged, written as UTF-8 to temp/
- 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.
6.5.2 Flight row context menu (list or gallery)
Section titled “6.5.2 Flight row context menu (list or gallery)”Inline groups, in this order:
- “Open in New Window” (
menu.button.openinwindow,macwindow.badge.plus). iPad and Mac only. - 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).
- “Share” (
- Recents only: “Save to flight log” (
folder.badge.plus) and “Remove from Recents” (clock.badge.xmark). - “Mark flight” ▸: Favorite / Contest / Field landing.
- 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.
6.5.3 Flight map overflow menu
Section titled “6.5.3 Flight map overflow menu”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)
- “IGC File” (
- “Upload flight” ▸: SkyLines / WeGlide / Charron. This works for unsaved service flights too.
- “Share” ▸:
- 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.
6.5.4 Map Image
Section titled “6.5.4 Map Image”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.
6.5.5 Upload availability and gating
Section titled “6.5.5 Upload availability and gating”A service is “connected” when its credentials are in the Keychain:
| Service | Requires |
|---|---|
| SkyLines | username + password + userId |
| WeGlide | API key + userId + date of birth |
| Charron | username + 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.
| State | Icon | Notes |
|---|---|---|
| Loading | spinner | Cannot be dismissed. |
| Failed | exclamationmark.triangle.fill red | Optional “Try again” button. |
| Done | checkmark.circle.fill green | — |
- Tapping the overlay dismisses the card once it is done or failed.
6.5.6 SkyLines upload
Section titled “6.5.6 SkyLines upload”Steps:
- The card shows “Uploading the flight to SkyLines…”.
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.
- If the “Publish on upload” setting is on:
POST /api/flights/{id}with{"privacyLevel":0}. - 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.
6.5.7 WeGlide upload
Section titled “6.5.7 WeGlide upload”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:
- The card shows “Uploading the flight to WeGlide…”.
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.
- Header:
- The card shows “Adding comment to the flight on WeGlide…”. It sends
PATCH /v1/flightdetail/{id}with{"comment":"Uploaded with Vulture"}. - 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.
6.5.8 Charron upload
Section titled “6.5.8 Charron upload”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:
| Section | Fields |
|---|---|
| “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:
- Log in to charronline.be with form fields
gebruikersnaam/paswoord. - Build a multipart request to
invoer2.php(fieldsimmatriculatie,motorzwever,wedstrijd,wedstrijdafstand,typeid, …). - 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”.
6.5.9 Report-a-problem mail
Section titled “6.5.9 Report-a-problem mail”Trigger: the triangle button in the detail popup or inspector.
When mail is available:
| Field | Value |
|---|---|
| To | me+vulture@fousa.be |
| Subject | “Reporting incorrect flight statistics” (flight.report.email.subject) |
| Body | "\n--\n<AppName> - <version (build)>\n<OS version string>" |
| Attachment | the IGC, MIME application/vnd.fai.igc, named flight.filename |
| Result | ignored; 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.
6.5.10 Pro gating for export
Section titled “6.5.10 Pro gating for export”| Not gated | Gated |
|---|---|
| IGC share, Map Image, report mail, mark, edit, delete | uploads (.uploads); opening more than one flight together (.severalFlights) |
6.6 Debrief and AI (“Flight Coach”)
Section titled “6.6 Debrief and AI (“Flight Coach”)”6.6.1 Three separate features
Section titled “6.6.1 Three separate features”| Feature | Status | What it is |
|---|---|---|
| Debrief | Ships | Fully deterministic, no LLM. A rating gauge plus fixed questions answered from the analysis. |
| Flight Coach chat | Built 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 Intents | Ships, no in-app UI | “Analyse flight”, “Analyse my last flight” and “Compare flights”, via FoundationModels guided generation (iOS 26+). Gated by Pro .debrief. |
6.6.2 Debrief entry and gating
Section titled “6.6.2 Debrief entry and gating”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:
| Context | Presentation |
|---|---|
| Phone | A draggable bottom card over the map |
| iPad | A 400×600 popover, with the map still interactive (passthrough) |
| Mac | The 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.
6.6.3 Debrief screen
Section titled “6.6.3 Debrief screen”Title: “Debrief”.
Content, in order:
- Rating gauge (§7) with the flight title “COMPID · Pilot” below it.
- 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 ” · ”.
- Title, score, and a chevron (or
- “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.
6.6.4 Questions
Section titled “6.6.4 Questions”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.
6.6.5 Flight Coach chat (if ported)
Section titled “6.6.5 Flight Coach chat (if ported)”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.”
6.7 Flight rating
Section titled “6.7 Flight rating”Code: Packages/FlightScreens/.../FlightRating/*.
6.7.1 Score
Section titled “6.7.1 Score”- 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.
6.7.2 Bands
Section titled “6.7.2 Bands”| Score | Band | Colour |
|---|---|---|
| < 400 | “Rough” | red |
| < 550 | “Fair” | orange |
| < 700 | “Solid” | yellow |
| < 850 | “Strong” | green |
| ≥ 850 | “Outstanding” | secondary blue |
6.7.3 Components
Section titled “6.7.3 Components”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:
Sport Range Glider 50–120 km/h Hang glider 20–60 km/h Paraglider 15–45 km/h This is the only sport-specific UI behaviour in the app.
Decisions
-
Starts at 100 and subtracts:
Condition Deduction 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”
6.7.4 Gauge
Section titled “6.7.4 Gauge”- 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.
6.7.5 Where it is shown
Section titled “6.7.5 Where it is shown”- Debrief.
- The Compare flights table: a “Rating” section with a “Score” row and one row per component, ranked as a podium.
6.8 Photos pager
Section titled “6.8 Photos pager”6.8.1 How photos are found
Section titled “6.8.1 How photos are found”- 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.
6.8.2 Entry
Section titled “6.8.2 Entry”- 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.
6.8.3 Pager
Section titled “6.8.3 Pager”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:
Label Value TIME HH:MM:SSALTITUDE pressure altitude, falling back to the photo’s GPS altitude SPEED groundspeed CLIMB vario
Pinch: a transient zoom of 1–4× that springs back on release.
6.9 Loading, empty and error states
Section titled “6.9 Loading, empty and error states”| Context | Behaviour |
|---|---|
| Summary or page with no data | Rows and sections are omitted. There is no explicit empty state; Flight and Info rows always exist. |
| Airspace | The row appears asynchronously once the scan reports crossings. With none, no row is shown. The list title updates live. |
| Phases filter with no matches | The section is empty, giving a blank list. There is no empty state; consider adding one on Android. |
| Edit flight restore | Spinner, 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 detail | Pro paywall; the plan is empty with fewer than 3 fixes. |
| Photos | Silent when none are found or permission is denied. |
| Uploads | Progress card states (§5.5). |
| Report mail | “No email account” alert. |
| Share of a flight with no filename or content | Silently skipped; the list path may open an empty share sheet. This is a bug; don’t copy it. |
6.10 Pro gating
Section titled “6.10 Pro gating”| Feature | Gate |
|---|---|
| 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)
- “Competion class” (
- 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.
6.12 Key files
Section titled “6.12 Key files”App module (Modules/App/Sources/):
Features/Flight/Detail/DetailPopupController.swiftDetailInspectorController.swiftDetailTabPageController.swiftDetailTabBridge.swiftDetailPopupViewModel.swiftDetailPopupActionPresenter.swiftDetailBarItem+UIKit.swiftDetailContent/FlightReportMailPresenter.swift
Features/Flight/EditFlight/CoreFlightEditStore.swiftUIViewController+EditFlight.swift
Features/Flight/Thermal/ThermalDetailViewController.swiftMap/Flight/Thermal/ThermalController.swiftMap/Flight/FlightMapViewController.swift(info toggle, subscribe, menus, share, snapshot)Map/Extensions/MapInspector.swiftMap/Extensions/MapPopoverPresentationDelegate.swiftMap/OptimizationDetail/*Map/Task/TaskMapViewController.swift(task popup)Shell/Menus/FlightMenuBuilder.swiftShell/Sidebar/List/Configuration/ListViewController+Menu.swiftShell/Sidebar/List/ListViewModel+Share.swiftSupport/Upload/**Map/Rendering/Snapshot/FlightSnapshotComposer.swiftApplication/FlightAI.swift
FlightScreens package (Packages/FlightScreens/Sources/FlightScreens/):
FlightDetail/Summary/*FlightDetail/Tabs/*FlightDetail/Rows/*FlightDetail/Models/*FlightDetail/DetailListStyle.swiftModels/DetailOptimizationItem*.swiftAirspaceInfringements/*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.mdDocs/flight-detail-inspector-investigation.mdDocs/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 element | Android / 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 grid | The 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 view | A form screen, then WebView in a dialog (or a Custom Tab, but the POST body requires a WebView postUrl). |
| Map Image composer | A 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 share | FileProvider URI with MIME application/vnd.fai.igc (fall back to text/plain for receivers that filter). Intent.createChooser. |
| Report mail | Intent(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. |
7. Strings and localisation
Section titled “7. Strings and localisation”7.1 Where the strings live
Section titled “7.1 Where the strings live”| Catalog | Keys | Used by | Notes |
|---|---|---|---|
Packages/DesignSystem/Sources/DesignSystem/Resources/Localizable.xcstrings | 957 | All 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.xcstrings | 510 | Shell, 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.xcstrings | 24 | Decoder error messages, etc. | |
Packages/Preferences/.../Localizable.xcstrings | 39 | Unit symbols, graph unit titles, play speeds | |
Modules/App/Resources/InfoPlist.xcstrings | 18 | Permission rationales (Bluetooth, location, photos, camera) | Maps to Android permission-rationale strings |
Modules/App/Resources/AppShortcuts.xcstrings | 12 | Siri phrases | Maps to App Actions shortcuts.xml |
Modules/Watch, QuickLook, Clip, MetarWidget, LiveActivity | — | Out of scope |
7.2 Languages
Section titled “7.2 Languages”-
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.mdalso mentions Slovenian, but there is noslin any catalog yet. -
Voice rules from
LOCALIZATION_STYLE.mdthat 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:
Locale Folder cs values-csde values-dees values-esfr values-frit values-itnl values-nlpl values-plen values(default) -
Declare the locales in
res/xml/locales_config.xmlfor 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.
-
Key names. Convert the dotted keys to Android names:
flight.statistics.data.durationbecomesflight_statistics_data_duration. Keep a mapping file so the iOS and Android keys stay traceable. -
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
zerofor English. Strings like “No flights” (flight.label.subtitlezero form) or “No infringements” need a separate<string>plus code that picks it when the count is 0.
- Convert
-
Format specifiers.
iOS Android %@%s%1$@%1$s%u,%lld%d%0.2f%.2f- Escape
',",@(at the start of a value),?and%(as%%). Useformatted="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.
- Escape
-
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. -
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.
-
Unit symbols and number formatting.
- iOS uses
String(format:)with the user’s units (decimals per unit, see §6.0b). Port theUnitFormatterrules exactly. - Use
NumberFormat/DecimalFormatwith the app locale, so the decimal separator follows the locale. The catalogs never hard-code it.
- iOS uses
-
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)andDateFormat.getBestDateTimePattern(locale, "EEEdMMM").
8. Screen inventory: iOS → Compose
Section titled “8. Screen inventory: iOS → Compose”Conventions.
- One feature module per area:
:feature:flights,:feature:import,:feature:flightmap. - Each screen has a
@Composable XScreen(state, onAction)plus a statelessXContent, and anXViewModel(Hilt,StateFlow<XUiState>,fun onAction(XAction)). - Each screen gets a small per-screen repository interface mirroring the iOS
<Screen>Providingprotocol, with fakes shared by@Previewand tests (the iOSMock<Dependency>pattern). - Component names are Material 3 / M3 Expressive (
androidx.compose.material31.4+ / 1.5 alpha). Expressive-only APIs are marked (E).
8.1 Shell and list
Section titled “8.1 Shell and list”| # | iOS screen / file(s) | Compose screen | ViewModel | M3 / M3 Expressive components | Notes / glass → Android |
|---|---|---|---|---|---|
| 1 | SplitViewController, 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). |
| 2 | ListViewController + 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. |
| 3 | SidebarGalleryView, GalleryCard, GalleryTile, TrackThumbnailTile, GalleryArtworkGenerator (Mapbox snapshots) | FlightGallery (inside FlightListScreen) | same VM | LazyVerticalGrid(GridCells.Fixed(n)), Card (radius 18, aspect 1:0.92), Modifier.animateItem(), overflow zoom items | Gallery 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. |
| 4 | SidebarListPreview (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). |
| 5 | ListFilterView / ListFilterViewModel (countries + 8 criteria tiles, live apply) | FlightFilterSheet | FlightFilterViewModel | ModalBottomSheet (compact) / anchored Popup card (expanded); FilterChip in FlowRow, or ToggleButton (E) tiles in a 3-col grid | Glass tiles → tonal toggle buttons. The active-filter cue is a filled IconButton. Consider adding “Clear” (deviation). |
| 6 | Select mode (invalidateEditing, SelectSessionControls, SelectionTick) | part of FlightListScreen | same 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). |
| 7 | FlightsStatisticsViewController (UIKit grid) + FlightsStatisticsViewModel | FlightStatisticsScreen | FlightStatisticsViewModel | Full-screen route; LargeTopAppBar “Statistics” + close; shared ComparisonGrid composable (frozen label column, year header pills as AssistChip/Surface), podium badges | Pro-gated. Reuse the Compare grid (#17). |
| 8 | Empty states (SidebarListPlaceholderView, ContentUnavailableView, .placeholderAction()) | EmptyState component | – | Column icon + titleLarge + bodyMedium + FilledTonalButton (glass capsule equivalent) | Shared in :core:designsystem. |
8.2 Import
Section titled “8.2 Import”| # | iOS screen / file(s) | Compose screen | ViewModel | Components | Notes |
|---|---|---|---|---|---|
| 9 | Document pickers (SplitViewController+Import) | – (activity-result launchers) | ImportViewModel (shell-scoped; batches, errors) | OpenMultipleDocuments launcher | Import runs in WorkManager/coroutine. Silent on success, as iOS (decisions §6). |
| 10 | Open-in / share / deep links (SceneDelegate+Routing) | – (intent handling in MainActivity) | ShellViewModel.handle(intent) | AlertDialog “Replace?” (Add / Replace / Cancel) | App Links on vulture.aero + vulture://. |
| 11 | ImportFlightsView / 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 / ErrorState | Connect prompt = EmptyState + “Open Settings”. |
| 12 | BluetoothDevicesView (scanner) | BluetoothDevicesScreen | BluetoothDevicesViewModel | Scaffold + TopAppBar (“Select a device”, close, LoadingIndicator while scanning); ListItem with LXNAV logo; “Unsupported” section disabled; radio-off EmptyState | Runtime permissions (BLUETOOTH_SCAN/CONNECT). |
| 13 | DeviceSendView + 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. |
| 14 | DeviceFlightsView (“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). |
| 15 | ErrorListView / single-error alert (UIViewController+Error) | ImportErrorsDialog | – | AlertDialog (one error) / full-screen Dialog with a list (several) |
8.3 Flight map and analysis
Section titled “8.3 Flight map and analysis”| # | iOS screen / file(s) | Compose screen | ViewModel | Components | Notes |
|---|---|---|---|---|---|
| 16 | FlightMapViewController + FlightMapView + FlightMapViewModel + Panel/** (map, glass panel, chips, barogram, play/unit controls, pills, overflow, drop overlay) | FlightMapScreen | FlightMapViewModel (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-graph | Largest screen (≈ 6–7 k loc on iOS). The glass panel and pills become tonal translucent surfaces (§9). |
| 17 | CompareFlightsViewController (UIKit) + CompareFlightsViewModel* + FlightRating | CompareFlightsScreen | CompareFlightsViewModel (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 bars | Shared grid with Statistics (#7). |
| 18 | RaceRankingView / RaceRankingViewController | RaceRankingPane (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. |
| 19 | DetailPopupController + DetailSummaryView / DetailSummaryViewModel | FlightDetailPane → DetailSummaryScreen | FlightDetailViewModel (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 report | Mac-style icon tabs (ButtonGroup (E)) on expanded only. |
| 20 | DetailTabFlightViewModel page | DetailFlightPage | shared FlightDetailViewModel (page projections) | Two-column info rows, bar Canvas (flaps) | |
| 21 | DetailTabPerformanceViewModel page | DetailPerformancePage | ″ | optimisation rows with chevron | Tap → highlight on map + dismiss sheet. |
| 22 | DetailTabThermalsViewModel page | DetailThermalsPage | ″ | info rows + histogram Canvas | |
| 23 | DetailTabPhasesViewModel page | DetailPhasesPage | ″ (+ persisted filter, scroll per flight) | phase ListItems with tinted badge + unread dot; filter DropdownMenu / SingleChoiceSegmentedButtonRow | Add an empty state (iOS has none). |
| 24 | DetailTabTaskViewModel page (declared task) | DetailTaskPage | ″ | metric grid, turnpoint rows, share-task menu | Editing turnpoints belongs to the Task feature (out of scope). |
| 25 | OptimizationDetailView / OptimizationDetailViewModel (+ mini map) | OptimizationDetailScreen | OptimizationDetailViewModel | static MapboxMap 200 dp card; metric grid; Legs ListItems + long-press menu | |
| 26 | AirspaceInfringementsView | AirspaceInfringementsPage | AirspaceInfringementsViewModel (live flow) | ListItem with class swatch + clock duration | |
| 27 | DetailTabInfoViewModel page | DetailInfoPage | shared VM | info rows | |
| 28 | ThermalDetailView / 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; RichTooltip | Pro .thermalAnalysis. |
| 29 | EditFlightView / EditFlightViewModel | EditFlightScreen | EditFlightViewModel | Full-screen dialog / sheet; OutlinedTextFields; Slider (steps); Switch rows; remarks multi-line field; destructive restore + LoadingIndicator (E) | Saves on Done and on dismiss. |
| 30 | FlightDebriefView / FlightDebriefViewModel (+ answer pages) | DebriefPane → DebriefAnswerScreen | DebriefViewModel | gauge Canvas; component rows with LinearProgressIndicator; blurred Pro lock + “Unlock with Pro” Button; answer ListItems with map glyph | Companion card / supporting pane. |
| 31 | AssetPagerView (photos) | FlightPhotosScreen | FlightPhotosViewModel | full-screen dark Dialog + HorizontalPager, readings strip, zoom | MediaStore permissions. |
| 32 | Upload flows (SkyLinesUploadViewModel, WeGlideUploadViewModel, CharronUpload*) | UploadProgressDialog, CharronUploadScreen | UploadViewModel, CharronUploadViewModel | Dialog with LoadingIndicator (E) / result; form + WebView | Pro .uploads. |
| 33 | Map Image (FlightSnapshotComposer) | – (use case) | FlightMapViewModel.shareSnapshot() | Share sheet via Intent.createChooser | |
| 34 | FlightChatView (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 idiom | Where it appears in this area | Android-native equivalent |
|---|---|---|
| Liquid Glass bar-button “pods” (grouped trailing buttons) | List nav bar, map nav bar | TopAppBar actions. On the map, an M3 Expressive HorizontalFloatingToolbar gives the floating grouped look natively. |
System overflow … (setOverflowItems, deferred menu, keepsMenuPresented) | List, map, sidebar | IconButton(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 map | Flight map bottom panel, thermal/debrief/ranking cards | Surface 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 add | FilledTonalButton (regular) and Button (prominent). Use M3 Expressive button sizes (ButtonDefaults.MediumContainerHeight / shapes that morph on press). |
| Glass tiles | Filter grid, compare headers, statistics year pills | Tonal ToggleButton (E) / FilterChip / AssistChip. |
UISearchController integrated in the bottom toolbar, .searchable minimize | List, service browser | M3 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, companions | Compact: ModalBottomSheet. Expanded: an anchored Popup / DropdownMenu-like card, or the supporting pane. |
| Inspector column | Detail on iPad/Mac | SupportingPaneScaffold supporting pane. |
| Context menu with preview (peek) | List rows | Long-press → ModalBottomSheet with the preview and actions, with a haptic (HapticFeedbackType.LongPress). |
Zoom navigation transition from a card (.detailZoom) | Gallery → map | Shared 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 subtitle | List, sidebar | LargeFlexibleTopAppBar / MediumFlexibleTopAppBar (E) with subtitle. |
| SF Symbols | everywhere | Material 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 select | HapticFeedbackType.SegmentFrequentTick (scrub), SegmentTick / ContextClick (select). |
UIActivityViewController | Share IGC / PNG | Intent.createChooser(ACTION_SEND/SEND_MULTIPLE) + FileProvider. |
| Mac menu bar, multi-window, Handoff | Many commands | Keyboard 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 mode | MaterialTheme.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”10.1 Risks
Section titled “10.1 Risks”| Risk | Why | Mitigation |
|---|---|---|
| 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 playback | Build 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 sync | One time source drives gliders, chips, pills, ranking, satellite frames and the handle | A single StateFlow<Instant> in FlightMapViewModel. Mapbox annotations updated through ViewAnnotation/PointAnnotationManager diffing, not by re-adding features. |
| Hidden/accidental iOS defaults | 10× play speed, Pressure altitude unit, gallery as default layout, silent duplicate import, unread on import | Copy the effective behaviour listed here, not the declared defaults. |
| Bottom panel ≠ Material sheet | Two-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 Android | List context-menu preview, filter popover, Compare info popovers | Bottom sheets and RichTooltip. Document these as accepted deviations. |
| Bluetooth LXNAV and FLARM Wi-Fi | BLE background behaviour, Android 12+ permissions, and the FLARM AP without internet | Foreground service. bindProcessToNetwork. Cleartext config for 10.10.10.10. Hardware test devices needed. |
| IGC MIME type | Android has no UTI system, and many apps send application/octet-stream | Accept */* on SEND/VIEW, then sniff the content. The Files picker opens with */* and filters afterwards. |
| Universal links | Need assetlinks.json on vulture.aero | Backend task. Coordinate with 04-integrations.md. |
| Strings | Plural zero forms, %@ conversion, two catalogs, typos | A conversion script plus a CI check that all 8 locales have every key (the iOS jq check). |
| Pro gating parity | Gates 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 thumbnails | Hundreds of Mapbox snapshots, generated in the background | Snapshotter 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 ideas | Notes-style tabs, filter chips, 3D, Flight Coach | Explicitly out of v1 parity. |
10.2 Open decisions for the Android team
Section titled “10.2 Open decisions for the Android team”- Keep the iOS “push the map over the list” on tablets, or use
ListDetailPaneScaffold? (Parity says push.) - Phone detail popup: a modal sheet (iOS today) or a non-modal partially expanded sheet (iOS planned “step 6”)?
- Play control: a split button (more Android-native) or parity with the tap-to-menu / long-press idiom?
Should silent operations get feedback, such as aDecided: no (decisions §6).Snackbarafter import and a “duplicate” notice?- 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)”| Block | Screens | Rough effort (1 experienced Compose dev) |
|---|---|---|
| Shell: library/sidebar, adaptive navigation, deep links/intents, keyboard shortcuts | 1–2 | 2–3 weeks |
| Flight list: list + gallery + thumbnails, sort, filter, search, select, menus, drag/drop, empty states, Recents | 4 | 3–4 weeks |
| Statistics + Compare grid (shared component), rating gauge | 2 | 2–3 weeks |
| Import: picker/intent pipeline, error UI, WeGlide/SkyLines browser, BLE (LXNAV) + FLARM transfer screens + foreground service | 6 | 4–5 weeks (the BLE/FLARM part needs hardware) |
| Flight map screen: panel, chips, barogram, playback, alignment/override, pills, overflow, drop, HUD, satellite toggle, snapshot share | 1 (big) | 7–9 weeks |
| Detail pane: summary + 7 pages + optimisation detail + infringements | 10 | 3–4 weeks |
| Thermal detail (3 custom charts), Debrief, Ranking, Photos | 4 | 4–5 weeks |
| Edit flight, uploads (SkyLines/WeGlide/Charron UI), report mail | 4 | 1.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).
