diff --git a/apps/example/ios/Podfile b/apps/example/ios/Podfile index 899b463d..dbbfe61a 100644 --- a/apps/example/ios/Podfile +++ b/apps/example/ios/Podfile @@ -8,6 +8,9 @@ require Pod::Executable.execute_command('node', ['-p', platform :ios, min_ios_version_supported prepare_react_native_project! +require 'json' +podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} + linkage = ENV['USE_FRAMEWORKS'] if linkage != nil Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green @@ -31,5 +34,16 @@ target 'example' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + + min_ios = podfile_properties['ios.deploymentTarget'] || '15.0' + + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + current = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if current && current.to_f < min_ios.to_f + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios + end + end + end end end diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 7ae6b4cc..ba050d0c 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2624,7 +2624,7 @@ PODS: - React-perflogger (= 0.83.5) - React-utils (= 0.83.5) - SocketRocket - - ReactNativeAutoPlay (0.5.4): + - ReactNativeAutoPlay (0.5.8): - boost - DoubleConversion - fast_float @@ -2995,10 +2995,10 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 105856c6da6fe71c545dec3c2466ae04f25f9a73 ReactCodegen: 507d02fe6f19c44a3274adbefd38a1a252843d24 ReactCommon: 506ceb24983c6b80e0075968be21aac74a367739 - ReactNativeAutoPlay: 3b9861a5d5c31de1488b21616e8e21c0519b805b + ReactNativeAutoPlay: 75b714066e0f88147d2686ea198f7e78d44f2da5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 897e8c1fb1fd80c3c01231ed4ec8c1ffa652c29f -PODFILE CHECKSUM: a5de45e2350a9515567a6e18ca8ceea718b48523 +PODFILE CHECKSUM: 363150b1f5d8414931fc45ff869b81c648a10fe1 COCOAPODS: 1.16.2 diff --git a/apps/example/src/AutoPlay.tsx b/apps/example/src/AutoPlay.tsx index 4252944a..663a8fb4 100644 --- a/apps/example/src/AutoPlay.tsx +++ b/apps/example/src/AutoPlay.tsx @@ -267,6 +267,94 @@ const registerRunnable = () => { onDidChangePanningInterface: (isPanningInterfaceVisible) => { console.log('onDidChangePanningInterface', isPanningInterfaceVisible); }, + optionsPanel: { + title: { text: 'options' }, + sections: [ + { + type: 'grid', + buttons: [ + { title: { text: '#1' }, image: { type: 'glyph', name: '123' }, onPress: () => {} }, + { title: { text: '#2' }, image: { type: 'glyph', name: '123' }, onPress: () => {} }, + ], + title: 'grid', + }, + { + title: 'radio', + type: 'list', + items: [ + { type: 'radio', selected: true, title: { text: '#1' }, onPress: () => {} }, + { type: 'radio', title: { text: '#2' }, onPress: () => {} }, + ], + }, + { + title: 'mixed list', + type: 'list', + items: [ + { type: 'default', title: { text: '#1' }, onPress: () => {} }, + { type: 'default', title: { text: '#2' }, onPress: () => {} }, + { type: 'text', title: { text: 'text only' } }, + { + type: 'toggle', + title: { text: 'toggle' }, + checked: false, + onPress: (_, checked) => { + console.log('*** toggle checked', checked); + }, + }, + ], + }, + { + type: 'charger', + location: { + coordinate: { + latitude: 0, + longitude: 0, + }, + travelEstimates: { + distance: { unit: 'kilometers', value: 12 }, + duration: { timezone: 'Europe/Vienna', seconds: 600 }, + visible: true, + }, + address: 'charger ave.', + name: 'Fast Network Inc.', + }, + outlets: [ + { connector: 'ccs2', powerKw: 300, voltage: 400, onPress: () => {} }, + { connector: 'chaDeMo', powerKw: 50, voltage: 400, onPress: () => {} }, + { connector: 'mennekes', powerKw: 3.7, voltage: 230, onPress: () => {} }, + ], + }, + ], + actions: [ + { + type: 'text', + title: 'rm panel', + onPress: (t) => { + t.updateOptionsPanel(); + }, + }, + { + type: 'image', + image: { type: 'glyph', name: 'rocket' }, + onPress: (t) => { + t.updateOptionsPanel({ + title: { text: 'options' }, + sections: [ + { + type: 'charger', + outlets: [ + { connector: 'ccs2', powerKw: 1500, voltage: 800 }, + { connector: 'ccs2', powerKw: 1500, voltage: 800 }, + { connector: 'ccs2', powerKw: 1500, voltage: 800 }, + ], + title: 'blazing fast charger', + }, + ], + }); + }, + }, + ], + }, }); rootTemplate.setRootTemplate(); }; diff --git a/apps/example/src/templates/AutoInformationTemplate.ts b/apps/example/src/templates/AutoInformationTemplate.ts index 7ce53ee9..09564e3b 100644 --- a/apps/example/src/templates/AutoInformationTemplate.ts +++ b/apps/example/src/templates/AutoInformationTemplate.ts @@ -12,50 +12,92 @@ const defaultColor = const getTemplate = (props?: { mapConfig?: InformationTemplateConfig['mapConfig']; }): InformationTemplate => { - return new InformationTemplate({ - title: { - text: `${TextPlaceholders.Distance} - ${TextPlaceholders.Duration}`, - distance: { unit: 'meters', value: 1234 }, - duration: 4711, + const title = { + text: `${TextPlaceholders.Distance} - ${TextPlaceholders.Duration}`, + distance: { unit: 'meters' as const, value: 1234 }, + duration: 4711, + }; + + const items: InformationTemplateConfig['items'] = [ + { + type: 'text', + title, + detailedText: title, + image: { name: 'text_ad', color: defaultColor, type: 'glyph' }, }, - mapConfig: props?.mapConfig, - headerActions: AutoTemplate.headerActions, - items: [ - { - type: 'text', - title: { - text: `${TextPlaceholders.Distance} - ${TextPlaceholders.Duration}`, - distance: { unit: 'meters', value: 1234 }, - duration: 4711, - }, - detailedText: { - text: `${TextPlaceholders.Distance} - ${TextPlaceholders.Duration}`, - distance: { unit: 'meters', value: 1234 }, - duration: 4711, - }, - image: { name: 'text_ad', color: defaultColor, type: 'glyph' }, - }, - { - type: 'text', - title: { text: 'Title 2' }, - detailedText: { text: 'Text2' }, - image: { name: 'text_ad', color: defaultColor, type: 'glyph' }, + { + type: 'text', + title: { text: 'Title 2' }, + detailedText: { text: 'Text2' }, + image: { name: 'text_ad', color: defaultColor, type: 'glyph' }, + }, + { + type: 'text', + title: { text: 'Title 3\nwith 2 rows\nCan i add one more? - oh no it truncates me :(' }, + detailedText: { + text: [ + 'some longer text', + 'with two rows', + 'do three work?', + 'oh yes they do!', + 'and another one? - nope, this one is cut off', + ].join('\n'), }, - { - type: 'text', - title: { text: 'Title 3\nwith 2 rows\nCan i add one more? - oh no it truncates me :(' }, - detailedText: { - text: [ - 'some longer text', - 'with two rows', - 'do three work?', - 'oh yes they do!', - 'and another one? - nope, this one is cut off', - ].join('\n'), - }, - image: { name: 'text_ad', color: defaultColor, type: 'glyph' }, + image: { name: 'text_ad', color: defaultColor, type: 'glyph' }, + }, + ]; + + const commonConfig = { + title, + headerActions: AutoTemplate.headerActions, + items, + onPopped: () => console.log('InformationTemplate onPopped'), + }; + + if (props?.mapConfig) { + return new InformationTemplate({ + ...commonConfig, + mapConfig: props.mapConfig, + actions: { + android: [ + { + type: 'textImage', + image: { name: 'alarm', type: 'glyph' }, + title: 'Confirm', + style: 'confirm', + onPress: (template) => { + console.log('*** Action 1'); + template.updateItems([ + { + title: { text: 'title' }, + type: 'text', + detailedText: { text: 'detailedText' }, + enabled: false, + image: { type: 'glyph', name: 'thumb_up', color: defaultColor }, + }, + ]); + }, + }, + { + type: 'text', + title: 'Default', + style: 'normal', + onPress: () => { + console.log('*** Action 2'); + }, + }, + ], + ios: [ + { type: 'text', title: 'Normal', onPress: () => {} }, + { type: 'image', image: { type: 'glyph', name: 'airlines' }, onPress: () => {} }, + ], }, - ], + }); + } + + return new InformationTemplate({ + ...commonConfig, + mapConfig: undefined, actions: { android: [ { @@ -121,7 +163,6 @@ const getTemplate = (props?: { }, ], }, - onPopped: () => console.log('InformationTemplate onPopped'), }); }; diff --git a/apps/example/src/templates/AutoListTemplate.ts b/apps/example/src/templates/AutoListTemplate.ts index 43f3e07b..e0fd5a81 100644 --- a/apps/example/src/templates/AutoListTemplate.ts +++ b/apps/example/src/templates/AutoListTemplate.ts @@ -1,5 +1,7 @@ import { + Constants, type DefaultRow, + HybridAutoPlay, ListTemplate, type ListTemplateConfig, type Section, @@ -7,14 +9,16 @@ import { type TextRow, type ToggleRow, } from '@iternio/react-native-auto-play'; +import type { WaypointRow } from '@iternio/react-native-auto-play/lib/utils/NitroSection'; import { DefaultTemplateImageColor } from '../config/Color'; import { AutoGridTemplate } from './AutoGridTemplate'; import { AutoTemplate } from './AutoTemplate'; const getRadioTemplate = (): ListTemplate => { - return new ListTemplate({ + const template = new ListTemplate({ title: { text: 'radios' }, headerActions: AutoTemplate.headerActions, + mapConfig: {}, sections: { type: 'radio', items: [ @@ -40,16 +44,46 @@ const getRadioTemplate = (): ListTemplate => { console.log('*** radio #3'); }, }, + { + type: 'radio', + onPress: () => { + HybridAutoPlay.popTemplate(); + }, + title: { text: 'pop template' }, + }, + { + type: 'radio', + onPress: () => { + HybridAutoPlay.popToRootTemplate(); + }, + title: { text: 'pop to root' }, + }, ], }, - onPopped: () => console.log('RadioTemplate onPopped'), + onPopped: () => console.log('RadioTemplate onPopped', template.id), + onDidAppear: () => { + console.log('RadioTemplate onDidAppear', template.id); + }, + onDidDisappear: () => { + console.log('RadioTemplate onDidDisappear', template.id); + }, + onWillAppear: () => { + console.log('RadioTemplate onWillAppear', template.id); + }, + onWillDisappear: () => { + console.log('RadioTemplate onWillDisappear', template.id); + }, }); + + return template; }; const checked: [boolean, boolean] = [true, false]; const getMainSection = (): Section => { - const items: Array | ToggleRow | TextRow> = [ + const items: Array< + DefaultRow | ToggleRow | TextRow | WaypointRow + > = [ { type: 'toggle', title: { text: 'toggle radio list' }, @@ -98,6 +132,46 @@ const getMainSection = (): Section => { }, browsable: true, }, + { + type: 'default', + onPress: () => { + HybridAutoPlay.popTemplate(); + }, + title: { text: 'pop template' }, + image: { type: 'glyph', name: 'arrow_back' }, + }, + { + type: 'default', + onPress: () => { + HybridAutoPlay.popToRootTemplate(); + }, + title: { text: 'pop to root' }, + image: { type: 'glyph', name: 'map' }, + }, + { + type: 'waypoint', + coordinate: { + latitude: 0, + longitude: 0, + }, + travelEstimates: { + distance: { unit: 'kilometers', value: 12 }, + duration: { timezone: 'Europe/Vienna', seconds: 600 }, + visible: true, + }, + title: { + text: Constants.isIos27OrGreater + ? 'some waypoint' + : `some waypoint (${TextPlaceholders.Duration})`, + }, + address: Constants.isIos27OrGreater + ? 'charger ave.' + : `charger ave. (${TextPlaceholders.Distance})`, + image: { + type: 'glyph', + name: 'pin_drop', + }, + }, ]; if (checked[0]) { @@ -128,7 +202,7 @@ const getMainSection = (): Section => { }; const getTemplate = (props?: { mapConfig?: ListTemplateConfig['mapConfig'] }): ListTemplate => { - return new ListTemplate({ + const template = new ListTemplate({ title: { text: `${TextPlaceholders.Distance} - ${TextPlaceholders.Duration}`, distance: { unit: 'meters', value: 1234 }, @@ -137,8 +211,22 @@ const getTemplate = (props?: { mapConfig?: ListTemplateConfig['mapConfig'] }): L mapConfig: props?.mapConfig, headerActions: AutoTemplate.headerActions, sections: getMainSection(), - onPopped: () => console.log('ListTemplate onPopped'), + onPopped: () => console.log('ListTemplate onPopped', template.id), + onDidAppear: () => { + console.log('ListTemplate onDidAppear', template.id); + }, + onDidDisappear: () => { + console.log('ListTemplate onDidDisappear', template.id); + }, + onWillAppear: () => { + console.log('ListTemplate onWillAppear', template.id); + }, + onWillDisappear: () => { + console.log('ListTemplate onWillDisappear', template.id); + }, }); + + return template; }; export const AutoListTemplate = { getTemplate }; diff --git a/apps/example/src/templates/AutoMessageTemplate.ts b/apps/example/src/templates/AutoMessageTemplate.ts index bd89ca27..12e308a9 100644 --- a/apps/example/src/templates/AutoMessageTemplate.ts +++ b/apps/example/src/templates/AutoMessageTemplate.ts @@ -14,11 +14,77 @@ const getTemplate = ({ message: AutoText; mapConfig?: MessageTemplateConfig['mapConfig']; }): MessageTemplate => { - return new MessageTemplate({ + const image: MessageTemplateConfig['image'] = { + name: 'info', + type: 'glyph', + color: DefaultTemplateImageColor, + }; + + const commonConfig = { title: { text: 'header title' }, message, - mapConfig, - image: { name: 'info', type: 'glyph', color: DefaultTemplateImageColor }, + image, + headerActions: AutoTemplate.headerActions.android, + onWillAppear: () => console.log('MessageTemplate onWillAppear'), + onDidAppear: () => console.log('MessageTemplate onDidAppear'), + onWillDisappear: () => console.log('MessageTemplate onWillDisappear'), + onDidDisappear: () => console.log('MessageTemplate onDidDisappear'), + onPopped: () => console.log('MessageTemplate onPopped'), + }; + + if (mapConfig) { + return new MessageTemplate({ + ...commonConfig, + mapConfig, + actions: { + android: [ + { + type: 'image', + image: { name: 'thumb_up', type: 'glyph', color: DefaultTemplateImageColor }, + onPress: () => { + console.log('yeah'); + void HybridAutoPlay.popTemplate(); + }, + }, + { + type: 'textImage', + image: { + name: 'thumb_down', + type: 'glyph', + color: DefaultTemplateImageColor, + }, + title: 'thumb down', + onPress: () => { + console.log('better luck next time'); + void HybridAutoPlay.popToRootTemplate(); + }, + }, + ], + ios: [ + { + type: 'text', + title: 'thumb up', + onPress: () => { + console.log('yeah'); + void HybridAutoPlay.popTemplate(); + }, + }, + { + type: 'image', + image: { name: 'thumb_down', type: 'glyph' }, + onPress: () => { + console.log('yeah'); + void HybridAutoPlay.popToRootTemplate(); + }, + }, + ], + }, + }); + } + + return new MessageTemplate({ + ...commonConfig, + mapConfig: undefined, actions: { android: [ { @@ -62,12 +128,6 @@ const getTemplate = ({ }, ], }, - headerActions: AutoTemplate.headerActions.android, - onWillAppear: () => console.log('MessageTemplate onWillAppear'), - onDidAppear: () => console.log('MessageTemplate onDidAppear'), - onWillDisappear: () => console.log('MessageTemplate onWillDisappear'), - onDidDisappear: () => console.log('MessageTemplate onDidDisappear'), - onPopped: () => console.log('MessageTemplate onPopped'), }); }; diff --git a/apps/example/src/templates/AutoSearchTemplate.ts b/apps/example/src/templates/AutoSearchTemplate.ts index 7520b02a..95b222f5 100644 --- a/apps/example/src/templates/AutoSearchTemplate.ts +++ b/apps/example/src/templates/AutoSearchTemplate.ts @@ -10,17 +10,17 @@ const getTemplate = ({ onSearchTextChanged: (searchText: string) => void; onSearchTextSubmitted: (searchText: string) => void; }): SearchTemplate => { - return new SearchTemplate({ + const template = new SearchTemplate({ title: { text: 'Search' }, headerActions: AutoTemplate.headerActions, searchHint, onSearchTextChanged, onSearchTextSubmitted, - onWillAppear: () => console.log('SearchTemplate onWillAppear'), - onDidAppear: () => console.log('SearchTemplate onDidAppear'), - onWillDisappear: () => console.log('SearchTemplate onWillDisappear'), - onDidDisappear: () => console.log('SearchTemplate onDidDisappear'), - onPopped: () => console.log('SearchTemplate onPopped'), + onWillAppear: () => console.log('SearchTemplate onWillAppear', template.id), + onDidAppear: () => console.log('SearchTemplate onDidAppear', template.id), + onWillDisappear: () => console.log('SearchTemplate onWillDisappear', template.id), + onDidDisappear: () => console.log('SearchTemplate onDidDisappear', template.id), + onPopped: () => console.log('SearchTemplate onPopped', template.id), results: { type: 'default', items: [ @@ -29,12 +29,16 @@ const getTemplate = ({ type: 'default', onPress: () => { console.log('*** initial #1'); - void HybridAutoPlay.popTemplate(); + HybridAutoPlay.popTemplate().catch((e) => { + console.log('*** failed to pop template', e); + }); }, }, ], }, }); + + return template; }; export const AutoSearchTemplate = { getTemplate }; diff --git a/apps/example/src/templates/AutoTemplate.ts b/apps/example/src/templates/AutoTemplate.ts index c4e38590..dbb31512 100644 --- a/apps/example/src/templates/AutoTemplate.ts +++ b/apps/example/src/templates/AutoTemplate.ts @@ -2,6 +2,7 @@ import { type Alert, type BackButton, CarPlayDashboard, + Constants, ErrorUtil, type HeaderActions, HybridAutoPlay, @@ -284,7 +285,22 @@ const mapHeaderActions: MapTemplateConfig['headerActions'] = { name: 'list', type: 'glyph', }, - onPress: () => AutoListTemplate.getTemplate().push(), + onPress: () => + AutoListTemplate.getTemplate({ + mapConfig: { + headerActions: { + android: [ + { + type: 'image', + image: { type: 'glyph', name: 'list' }, + onPress: () => { + AutoListTemplate.getTemplate({ mapConfig: {} }).push(); + }, + }, + ], + }, + }, + }).push(), }, { type: 'image', @@ -312,7 +328,14 @@ const mapHeaderActions: MapTemplateConfig['headerActions'] = { name: 'list', type: 'glyph', }, - onPress: () => AutoListTemplate.getTemplate().push(), + onPress: () => + AutoListTemplate.getTemplate({ + mapConfig: { + mapButtons: [ + { type: 'custom', image: { type: 'glyph', name: 'bolt' }, onPress: () => {} }, + ], + }, + }).push(), }, ], trailingNavigationBarButtons: [ @@ -377,14 +400,20 @@ const AutoAlert = (remaining: number): Alert => ({ priority: 'medium', }); +const mapButtonConfig = { + backgroundColor: Constants.isIos27OrGreater + ? undefined + : { darkColor: 'rgba(0, 0, 0, 0.5)', lightColor: 'rgba(255, 255, 255, 0.7)' }, + color: { darkColor: 'white', lightColor: 'black' }, + type: 'glyph' as const, +}; + const mapButtons: MapTemplateConfig['mapButtons'] = [ { type: 'custom', image: { + ...mapButtonConfig, name: 'ev_charger', - color: { darkColor: 'rgba(255, 0, 0, 1)', lightColor: 'rgba(0, 255, 0, 1)' }, - backgroundColor: 'rgba(66, 66, 66, 0.5)', - type: 'glyph', }, onPress: (template) => { var remaining = 10000; @@ -412,10 +441,8 @@ const mapButtons: MapTemplateConfig['mapButtons'] = [ { type: 'custom', image: { + ...mapButtonConfig, name: 'map', - color: { darkColor: 'rgba(255, 0, 0, 1)', lightColor: 'rgba(0, 255, 0, 1)' }, - backgroundColor: 'rgba(66, 66, 66, 0.5)', - type: 'glyph', }, onPress: () => { AutoGridTemplate.getTemplate({ @@ -452,7 +479,6 @@ const mapButtons: MapTemplateConfig['mapButtons'] = [ }, ], headerActions: { - // ios does not support map with template, so no need to specify headers for ios here android: [ { type: 'image', @@ -483,10 +509,8 @@ const mapButtons: MapTemplateConfig['mapButtons'] = [ { type: 'custom', image: { + ...mapButtonConfig, name: 'search', - color: { darkColor: 'rgba(255, 0, 0, 1)', lightColor: 'rgba(0, 255, 0, 1)' }, - backgroundColor: 'rgba(66, 66, 66, 0.5)', - type: 'glyph', }, onPress: () => { let timeout: number; @@ -557,7 +581,7 @@ const mapButtons: MapTemplateConfig['mapButtons'] = [ { type: 'pan', image: { - type: 'glyph', + ...mapButtonConfig, name: 'open_with', }, }, diff --git a/packages/react-native-autoplay/README.md b/packages/react-native-autoplay/README.md index 585a5ca0..3ed0458a 100644 --- a/packages/react-native-autoplay/README.md +++ b/packages/react-native-autoplay/README.md @@ -542,6 +542,7 @@ Below is a concise overview of the most important props per template. Optional p | `headerActions` | `MapHeaderActions` | ❌ | Top action strip. See **Header Actions** below. | | `mapButtons` | `MapButtons` | ❌ | 1–4 map buttons shown on the map. To get working gestures on the MapTemplate running on Android Auto you have to add a `MapPanButton` | | `visibleTravelEstimate` | `'first'` `'last'` | ❌ | Which travel estimate to display. | +| `optionsPanel` | `OptionsPanelConfig` | ❌ | **iOS 27+ only, no-op on Android.** Panel shown when tapping the ellipsis button next to the travel estimates during active navigation. See **Options Panel** below. | | `onDidPan` / `onDidUpdateZoomGestureWithCenter` | callbacks | ❌ | Map gesture events. | | `onAppearanceDidChange` | `(colorScheme) => void` | ❌ | Listen for light/dark mode changes. | | `onAutoDriveEnabled` | `(template) => void` | ⚠️ | Android-only auto drive callback. Make sure to take action when receiving this and simulate a drive to the set destination. [Check Android docs for details](https://developer.android.com/reference/androidx/car/app/navigation/NavigationManagerCallback#onAutoDriveEnabled()) | @@ -553,7 +554,7 @@ Below is a concise overview of the most important props per template. Optional p | `title` | `AutoText` | ✅ | Header title. | | `sections` | `Section` | ❌ | List sections/rows. Not providing anything here will result in a loading indicator on Android and an empty list on iOS. | | `headerActions` | `HeaderActions` | ❌ | Header actions. See **Header Actions** below. | -| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. | +| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. **iOS 27+**: renders as a `CPMapPanel` on the current root map template instead. See **Map + Content** below. | #### GridTemplateConfig @@ -562,7 +563,7 @@ Below is a concise overview of the most important props per template. Optional p | `title` | `AutoText` | ✅ | Header title. | | `buttons` | `GridButton[]` | ✅ | Grid items. Providing an empty array will result in a loading indicator on Android and an empty template on iOS. | | `headerActions` | `HeaderActions` | ❌ | Header actions. See **Header Actions** below. | -| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. | +| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. **iOS 27+**: renders as a `CPMapPanel` on the current root map template instead. See **Map + Content** below. | #### SearchTemplateConfig @@ -582,9 +583,9 @@ Below is a concise overview of the most important props per template. Optional p | --- | --- | --- | --- | | `title` | `AutoText` | ✅ | Header title. | | `items` | `InformationItems` | ❌ | 1–4 rows. | -| `actions` | platform-specific | ❌ | Up to 2 buttons on Android, up to 3 on iOS. | +| `actions` | platform-specific | ❌ | Up to 2 buttons on Android, up to 3 on iOS. **iOS 27+ with `mapConfig` set**: at most 1 `TextButton` plus 1 icon-only `ImageButton`, enforced at the type level. | | `headerActions` | `HeaderActions` | ❌ | Header actions. See **Header Actions** below. | -| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. | +| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. **iOS 27+**: renders as a `CPMapPanel` on the current root map template instead. See **Map + Content** below. | #### MessageTemplateConfig @@ -593,9 +594,9 @@ Below is a concise overview of the most important props per template. Optional p | `message` | `AutoText` | ✅ | Main message text. | | `title` | `AutoText` | ❌ | Android header title. | | `image` | `AutoImage` | ❌ | Android-only image above the message. | -| `actions` | platform-specific | ❌ | Up to 2 buttons on Android, up to 3 on iOS. | +| `actions` | platform-specific | ❌ | Up to 2 buttons on Android, up to 3 on iOS. **iOS 27+ with `mapConfig` set**: at most 1 `TextButton` plus 1 icon-only `ImageButton`, enforced at the type level. | | `headerActions` | `HeaderActionsAndroid` | ❌ | Android-only header actions. | -| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. | +| `mapConfig` | `BaseMapTemplateConfig` | ❌ | Android map-with-content layout. **iOS 27+**: renders as a `CPMapPanel` on the current root map template instead, trading the usual full-screen modal alert for panel content. See **Map + Content** below. | #### SignInTemplateConfig (Android-only) @@ -818,11 +819,11 @@ useEffect(() => { | Template | Purpose | Notes | | --- | --- | --- | | `MapTemplate` | Navigation, map rendering | Use as root; supports map buttons & navigation APIs. | -| `ListTemplate` | Lists/menus | Supports sections, radio/toggle rows. | -| `GridTemplate` | Action grid | Use `GridButton` items. | +| `ListTemplate` | Lists/menus | Supports sections, radio/toggle rows. Can render as a CarPlay map panel, see **Map + Content**. | +| `GridTemplate` | Action grid | Use `GridButton` items. Can render as a CarPlay map panel, see **Map + Content**. | | `SearchTemplate` | Search UI | Android-only search bar callbacks. | -| `InformationTemplate` | Info panels | Android uses PaneTemplate; iOS uses InformationTemplate. | -| `MessageTemplate` | Modal messages | Always shown on top until popped. | +| `InformationTemplate` | Info panels | Android uses PaneTemplate; iOS uses InformationTemplate. Can render as a CarPlay map panel, see **Map + Content**. | +| `MessageTemplate` | Modal messages | Always shown on top until popped (a true full-screen modal alert on iOS). Can render as a CarPlay map panel instead, see **Map + Content**. | **Template quick examples:** @@ -843,6 +844,132 @@ new ListTemplate({ }).push(); ``` +### Map + Content (`mapConfig`) + +`ListTemplate`, `GridTemplate`, `InformationTemplate`, and `MessageTemplate` all accept an optional `mapConfig` prop. Setting it (an empty object is enough — no actions need to be specified) gives the template a map background instead of its normal full-screen presentation. The two platforms implement this completely differently, so behavior and limitations differ accordingly. + +```ts +new ListTemplate({ + title: { text: 'Nearby' }, + sections: [{ type: 'default', title: 'Stops', items: [{ type: 'default', title: { text: 'Charger' }, onPress: () => {} }] }], + mapConfig: {}, +}).push(); +``` + +#### Android + +`mapConfig` wraps the template in a `MapWithContentTemplate`, giving it a map background while the template's own content (list, grid, info, or message) is laid out on top. + +#### iOS (27+) + +`mapConfig` instead renders the template as a [`CPMapPanel`](https://developer.apple.com/documentation/carplay/cpmappanel) — an overlay shown **on the current root map template** (a `MapTemplate` set via `setRootTemplate()`). On iOS versions below 27, `mapConfig` is currently a no-op and the template renders normally (there is no map-background equivalent pre-27). + +```ts +// Root map template must already be set for the panel to have somewhere to attach to +new MapTemplate({ component: MapScreen, onStopNavigation: () => {} }).setRootTemplate(); + +// Pushing this on top now shows it as an overlay panel on the map, instead of a full-screen list +new ListTemplate({ + title: { text: 'Nearby' }, + sections: [{ type: 'default', title: 'Stops', items: [{ type: 'default', title: { text: 'Charger' }, onPress: () => {} }] }], + mapConfig: {}, +}).push(); +``` + +**Panels share the same push/pop stack as regular templates** — this is a library-level abstraction, not how Apple's API actually works. From your JS code's perspective, `.push()`, `HybridAutoPlay.popTemplate()`/`popToRootTemplate()`/`popToTemplate()`, and the lifecycle callbacks (`onWillAppear`, `onDidAppear`, etc.) behave the same whether the top of the stack is a panel or a regular pushed template — you can mix and pop through both without caring which is which. Natively, however, `CPMapPanel` is **not** part of `CPInterfaceController`'s template stack at all — Apple's API gives it its own, completely separate panel stack that lives on the `CPMapTemplate` that pushed it (`pushPanel`/`popPanel`/`CPMapPanelDelegate`, unrelated to `CPInterfaceController.pushTemplate`/`popTemplate`). This library tracks both stacks together internally and presents one unified stack to JS, so if you go looking at Apple's CarPlay documentation expecting to see panels integrated with `CPInterfaceController`, you won't find it there — that integration is something this library provides on top. + +**Things that behave differently in panel mode:** + +- **`headerActions`/`mapButtons` ownership**: while a panel is shown, it takes over the root map template's bar buttons and floating map buttons — using the panel template's **own** `headerActions`/`mapConfig.mapButtons`, not `mapConfig.headerActions` (which is Android-only; on iOS it's ignored, since there's no separate header for the map behind a panel). The map template's own buttons are restored automatically once the panel is popped. +- **`backButton` is applied but likely redundant**: the panel itself always shows its own non-customizable close/back control, separate from anything you set. If you also specify `headerActions.ios.backButton`, this library applies it as-is to the root map template's nav bar, so you can end up with two back-like controls on screen at once (the panel's own, plus yours). Whether to include `backButton` is therefore a runtime decision: check the OS version (e.g. `Constants.isIos27OrGreater`) and omit `backButton` only when the device will actually render this as a panel. +- **`InformationTemplate`/`MessageTemplate` `actions`**: a `CPMapPanel`'s button configuration only supports one `TextButton` (with a title) plus one optional icon-only `ImageButton` (any title on it is dropped natively) — far fewer than the up-to-3-`TextButton` shape available without `mapConfig`. The type system enforces this: `actions.ios` is restricted to `[TextButton]` or `[TextButton, ImageButton]` whenever `mapConfig` is set. +- **`MessageTemplate` stops being a true modal**: normally `MessageTemplate` is a full-screen, blocking alert (`CPAlertTemplate`) that covers everything regardless of OS version. With `mapConfig` set, it instead becomes dismissible panel content in the regular push/pop stack — a deliberate trade-off, not a partial implementation. + +**Known iOS 27 beta limitations** (not something fixable in this library — re-test against newer betas): + +- The optional icon-only `symbolButton` in a panel's button configuration does not appear to respond to taps at all on this beta — the button renders correctly, but its press handler is never invoked by CarPlay. + +### Waypoint Rows (`type: 'waypoint'`) + +Any list section (`ListTemplate.sections`, or an `OptionsPanel` list section — see below) can include a `waypoint` row alongside the usual `default`/`toggle`/`radio`/`text` rows: + +```ts +{ + type: 'waypoint', + title: { text: 'Supercharger' }, + address: 'Main St 1\n1234 Springfield', + coordinate: { latitude: 48.2, longitude: 16.37 }, + travelEstimates: { + distance: { unit: 'kilometers', value: 12 }, + duration: { timezone: 'Europe/Vienna', seconds: 600 }, + visible: true, + }, + image: { type: 'glyph', name: 'pin_drop' }, + onPress: () => {}, +} +``` + +**iOS 27+ inside a `CPMapPanel`** (i.e. the enclosing `ListTemplate`/`GridTemplate` has `mapConfig` set, or this row is part of an `OptionsPanel` list section): renders as a real [`CPMapTemplateWaypoint`](https://developer.apple.com/documentation/carplay/cpmaptemplatewaypoint) item — `title` becomes the name, `address` the address, `image` the leading image (see the known-issue note below on image sizing). `travelEstimates.distance`/`.duration` are always sent to the native waypoint object (CarPlay requires them structurally), but they're **not shown by the waypoint item itself** — set `travelEstimates.visible: true` to additionally insert a sibling native [`CPTravelEstimates`](https://developer.apple.com/documentation/carplay/cptravelestimates) row right after it. This is a static snapshot, not live-updating — re-set `distance`/`duration` yourself (e.g. via `updateSections`/`updateOptionsPanel`) if it needs to track a changing location; there's no lighter-weight update path for just this value today. + +**Everywhere else** (non-panel `ListTemplate`, Android, iOS < 27): falls back to a plain row, using `title` as the row title and `address` as the detail text — `travelEstimates.visible` has no effect here. Instead, reference `TextPlaceholders.Distance`/`TextPlaceholders.Duration` inside `title.text`/`address` yourself and this library fills them in automatically (the same substitution mechanism `AutoText.distance`/`.duration` already do everywhere): + +```ts +{ + type: 'waypoint', + title: { text: `Supercharger (${TextPlaceholders.Distance})` }, + address: `Main St 1 · ${TextPlaceholders.Duration} away`, + travelEstimates: { distance: { unit: 'kilometers', value: 12 }, duration: { timezone: 'Europe/Vienna', seconds: 600 } }, + coordinate: { latitude: 48.2, longitude: 16.37 }, + onPress: () => {}, +} +``` + +### Options Panel (`optionsPanel`, iOS 27+) + +`MapTemplate`'s `optionsPanel` prop configures the panel CarPlay shows when the user taps the ellipsis button next to the travel estimates during active navigation. It's a no-op on Android and on iOS below 27. + +```ts +mapTemplate.updateOptionsPanel({ + title: { text: 'Trip options' }, + sections: [ + { + type: 'list', + title: 'Route', + items: [{ type: 'default', title: { text: 'Avoid tolls' }, onPress: () => {} }], + }, + { + type: 'charger', + title: 'Charger', + location: { + name: 'Fast Network Inc.', + address: 'Main St 1', + coordinate: { latitude: 48.2, longitude: 16.37 }, + travelEstimates: { + distance: { unit: 'kilometers', value: 12 }, + duration: { timezone: 'Europe/Vienna', seconds: 600 }, + visible: true, + }, + onPress: () => {}, + }, + outlets: [{ connector: 'ccs2', voltage: 400, powerKw: 300, onPress: () => {} }], + }, + ], +}); +``` + +A section is one of: + +| `type` | Renders as | Notes | +| --- | --- | --- | +| `'list'` | Rows (`default`/`toggle`/`radio`/`text`/`waypoint`) | Same row types and behavior as a regular `ListTemplate` section. | +| `'grid'` | A row of `GridButton`s | Same shape as `GridTemplate.buttons`. | +| `'charger'` | One `CPChargingStationConnection` item per outlet | `outlets[].connector` is one of `ccs1`/`ccs2`/`j1772`/`chaDeMo`/`mennekes`/`gbtDC`/`gbtAC`/`nacsDC`/`nacsAC`; `powerKw` above 1000 is shown in MW natively. `location` is optional and behaves exactly like a `waypoint` row's panel behavior above (own `CPMapTemplateWaypoint` item, `travelEstimates.visible` for the sibling estimate row) — except it uses `location.name` instead of a `title`, since the section's own `title` is already shown as the header (repeating it on the item would look redundant). | + +**Known iOS 27 beta issues affecting waypoint/options-panel content** (not fixable in this library — re-test against newer betas; each was confirmed by direct testing, several already have Apple Feedback reports filed): + +- **Custom (non-system) images are unreliable across several of these newer panel APIs.** A `CPMapButton`/header-action image needed a workaround (`noImageAsset: true` — the normal `UIImageAsset`-wrapped light/dark variant path scales incorrectly there) that's already applied internally, so map buttons/header actions are unaffected. A `waypoint` row's/`ChargerLocation`'s `image`, however, has no known-good size — everything from explicit point sizes to real custom `UIImage.isSymbolImage` assets was tried without a reliable, correctly-sized result; only genuine **system** symbols (`UIImage(systemName:)`) size correctly there. Expect `image` on a waypoint/charger row to render, but not necessarily at a sensible size. +- **`CPListItem.accessoryImage` (used for `toggle` rows) renders at some fixed, undersized footprint on iOS 27, regardless of the image's content, size, scale, or whether it's a real symbol image** — confirmed via extensive testing (content proportions, render scale, post-hoc scale metadata, genuine `UIImage.isSymbolImage` assets from both the app's own bundle and a library-owned resource bundle). Reproduces on a plain (non-panel) `ListTemplate` too, so it isn't specific to panels or to this library's usage of the API. No workaround found; filed as Apple Feedback. + ### Voice Input The library provides a cross-platform in-app voice recording API built on top of the car microphone (when connected) or the device microphone (when no car is connected). The voice API lives in `HybridVoice`. @@ -1095,6 +1222,8 @@ In case you are using Expo SDK >= 56 make sure to set `buildReactNativeFromSourc // Hide the splash screen for the CarPlay screen hideAsync(AutoPlayModules.AutoPlayRoot); ``` +- **CarPlay map panels (iOS 27 beta)**: a panel's optional icon-only `symbolButton` does not respond to taps. See **Map + Content** above for details. This is a beta platform limitation, not a bug in this library — re-test against newer iOS 27 betas. +- **Waypoint/options-panel images and toggle-row sizing (iOS 27 beta)**: custom images on a `waypoint` row/`ChargerLocation` have no reliable size, and `CPListItem.accessoryImage` (`toggle` rows) renders at an undersized fixed footprint regardless of the image supplied. See **Waypoint Rows** above for details. Beta platform limitations, not bugs in this library — an Apple Feedback report has been filed for the `accessoryImage` issue. ### Android - **Broken exceptions with `react-native`** up to version 0.79 When using react-native before 0.80.0 exceptions are broken and are reported as `Unknown runtime_error` or similar. diff --git a/packages/react-native-autoplay/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridMapTemplate.kt b/packages/react-native-autoplay/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridMapTemplate.kt index 205c812b..36978e64 100644 --- a/packages/react-native-autoplay/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridMapTemplate.kt +++ b/packages/react-native-autoplay/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridMapTemplate.kt @@ -158,4 +158,8 @@ class HybridMapTemplate : HybridMapTemplateSpec() { override fun setManeuverState(templateId: String, state: ManeuverState) { // Android Auto does not have an equivalent to CPManeuverState } + + override fun updateOptionsPanel(templateId: String, config: NitroOptionsPanelConfig?) { + // Android Auto has no equivalent to CarPlay's navigation session options panel + } } \ No newline at end of file diff --git a/packages/react-native-autoplay/ios/Types.swift b/packages/react-native-autoplay/ios/Types.swift index c1845a2f..64ff623d 100644 --- a/packages/react-native-autoplay/ios/Types.swift +++ b/packages/react-native-autoplay/ios/Types.swift @@ -20,4 +20,6 @@ enum AutoPlayError: LocalizedError { case noUiWindow(String) case initReactRootViewFailed(String) case voiceInputCancelled + case rootTemplateNotMapTemplate(String) + case templateIsMapPanel(String) } diff --git a/packages/react-native-autoplay/ios/hybrid/HybridAutoPlay.swift b/packages/react-native-autoplay/ios/hybrid/HybridAutoPlay.swift index bbc490eb..96002378 100644 --- a/packages/react-native-autoplay/ios/hybrid/HybridAutoPlay.swift +++ b/packages/react-native-autoplay/ios/hybrid/HybridAutoPlay.swift @@ -23,6 +23,12 @@ class HybridAutoPlay: HybridAutoPlaySpec { private static var safeAreaInsetsListeners = [String: [SafeAreaListener]]() override init() { + /// init the calendar to make Parser.parseText not crash on iOS 27 beta 3 + /// TODO: recheck on RC/final release + DispatchQueue.main.async { + _ = DateComponentsFormatter().string(from: 0) + } + HybridAutoPlay.listeners.removeAll() HybridAutoPlay.renderStateListeners.removeAll() HybridAutoPlay.safeAreaInsetsListeners.removeAll() @@ -137,7 +143,7 @@ class HybridAutoPlay: HybridAutoPlaySpec { templateId: templateId ) - let carPlayTemplate = template.getTemplate() + let carPlayTemplate = try template.getTemplate() if carPlayTemplate is CPMapTemplate { try await MainActor.run { @@ -169,23 +175,31 @@ class HybridAutoPlay: HybridAutoPlaySpec { await template.invalidate() - let carPlayTemplate = template.getTemplate() - - if carPlayTemplate is CPAlertTemplate { - let animated = try await !interfaceController.dismissTemplate( - animated: false - ) - - let _ = try await interfaceController.presentTemplate( - carPlayTemplate, - animated: animated + if #available(iOS 27.0, *), let panel = template.getPanel() as? CPMapPanel { + try await interfaceController.pushPanel( + panel, + templateId: templateId ) } else { - let _ = try await interfaceController.pushTemplate( - carPlayTemplate, - animated: true - ) + let carPlayTemplate = try template.getTemplate() + + if carPlayTemplate is CPAlertTemplate { + let animated = try await !interfaceController.dismissTemplate( + animated: false + ) + + let _ = try await interfaceController.presentTemplate( + carPlayTemplate, + animated: animated + ) + } + else { + let _ = try await interfaceController.pushTemplate( + carPlayTemplate, + animated: true + ) + } } if let autoDismissMs = template.autoDismissMs { @@ -194,7 +208,7 @@ class HybridAutoPlay: HybridAutoPlaySpec { nanoseconds: UInt64(autoDismissMs) * 1_000_000 ) - if interfaceController.topTemplateId == templateId + if interfaceController.isTopEntry(templateId: templateId) || interfaceController.interfaceController .presentedTemplate?.id == templateId { diff --git a/packages/react-native-autoplay/ios/hybrid/HybridMapTemplate.swift b/packages/react-native-autoplay/ios/hybrid/HybridMapTemplate.swift index 275e99df..24e76ad8 100644 --- a/packages/react-native-autoplay/ios/hybrid/HybridMapTemplate.swift +++ b/packages/react-native-autoplay/ios/hybrid/HybridMapTemplate.swift @@ -168,4 +168,11 @@ class HybridMapTemplate: HybridMapTemplateSpec { template.setManeuverState(state: state) } } + + func updateOptionsPanel(templateId: String, config: NitroOptionsPanelConfig?) throws { + try RootModule.withAutoPlayTemplate(templateId: templateId) { + (template: MapTemplate) in + template.updateOptionsPanel(config: config) + } + } } diff --git a/packages/react-native-autoplay/ios/scenes/AutoPlayInterfaceController.swift b/packages/react-native-autoplay/ios/scenes/AutoPlayInterfaceController.swift index 5008b1ed..b1eda7ad 100644 --- a/packages/react-native-autoplay/ios/scenes/AutoPlayInterfaceController.swift +++ b/packages/react-native-autoplay/ios/scenes/AutoPlayInterfaceController.swift @@ -11,6 +11,24 @@ import CarPlay class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate { let interfaceController: CPInterfaceController + /// `CPMapTemplate` exposes no way to inspect its panel stack (only push/pop operations), + /// so this mirrors the combined logical navigation stack of regular pushed templates and + /// panels pushed onto a `CPMapTemplate`, in push order, to know what `popTemplate` etc. + /// need to pop next. + private enum NavigationEntry { + case template(id: String) + case panel(id: String, mapTemplate: CPMapTemplate) + + var id: String { + switch self { + case .template(let id): return id + case .panel(let id, _): return id + } + } + } + + private var navigationStack: [NavigationEntry] = [] + init( interfaceController: CPInterfaceController ) { @@ -29,18 +47,10 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate { interfaceController.rootTemplate } - var topTemplate: CPTemplate? { - interfaceController.topTemplate - } - var templates: [CPTemplate] { interfaceController.templates } - var topTemplateId: String? { - return interfaceController.topTemplate?.id - } - var rootTemplateId: String? { return interfaceController.rootTemplate.id } @@ -49,99 +59,195 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate { _ templateToPush: CPTemplate, animated: Bool ) async throws -> Bool { - return try await interfaceController.pushTemplate( + let result = try await interfaceController.pushTemplate( templateToPush, animated: animated ) + + navigationStack.append(.template(id: templateToPush.id)) + + return result + } + + /// Pushes `panel` onto the current root template, which must already be a `CPMapTemplate` + /// (e.g. a `MapTemplate` previously set via `setRootTemplate`). + @available(iOS 27.0, *) + func pushPanel( + _ panel: CPMapPanel, + templateId: String + ) async throws { + guard let mapTemplate = rootTemplate as? CPMapTemplate else { + throw AutoPlayError.rootTemplateNotMapTemplate( + "\(templateId) has mapConfig set, which requires the root template to be a CPMapTemplate" + ) + } + + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + mapTemplate.pushPanel(panel) { _, error in + if let error { + continuation.resume(throwing: error) + return + } + continuation.resume() + } + } + + navigationStack.append(.panel(id: templateId, mapTemplate: mapTemplate)) } func setRootTemplate( _ rootTemplate: CPTemplate, animated: Bool ) async throws -> Bool { - return try await interfaceController.setRootTemplate( + let result = try await interfaceController.setRootTemplate( rootTemplate, animated: animated ) + + // TODO: check if we need to hide any panels or if setRootTemplate handles this + + navigationStack = [.template(id: rootTemplate.id)] + + return result + } + + /// Whether `templateId` is the top entry of the combined template/panel navigation stack. + func isTopEntry(templateId: String) -> Bool { + return navigationStack.last?.id == templateId + } + + /// Removes the navigation-stack entry for `templateId` if present; idempotent if it's + /// already gone. For `.template` entries, `popTopEntry` removes the entry directly for + /// app-initiated pops, and `templateDidDisappear` calls this for CarPlay-initiated pops + /// (e.g. its built-in back button) that `popTopEntry` never sees. For `.panel` entries, + /// `popTopEntry` doesn't touch `navigationStack` at all — `CPMapPanelDelegate.panelDidHide` + /// calling this is the only place a panel entry is removed, covering both app-initiated + /// and CarPlay-initiated pops alike. + func removeNavigationEntryIfPresent(templateId: String) { + navigationStack.removeAll { $0.id == templateId } + } + + /// Ids of all panel entries currently in the navigation stack, in push order. `navigationStack` + /// itself is private, so this is the minimal data `CPMapPanelDelegate` callbacks need to + /// simulate cover/reveal lifecycle events themselves — `CPMapPanelDelegate` has no + /// equivalent of `CPInterfaceControllerDelegate` telling a covered/revealed panel about it. + @available(iOS 27.0, *) + var panelTemplateIds: [String] { + navigationStack.compactMap { entry in + if case .panel(let id, _) = entry { return id } + return nil + } + } + + /// Whether any panel exists in the stack, even if currently covered by a regular template. + @available(iOS 27.0, *) + var hasPanel: Bool { + !panelTemplateIds.isEmpty } func popTemplate( animated: Bool ) async throws -> String? { - guard let templateId = topTemplateId else { return nil } + return try await popTopEntry(animated: animated) + } - // Ensure at least one template remains - guard templates.count > 1 else { return nil } + /// Pops whatever is on top of the combined navigation stack: a regular pushed template via + /// `CPInterfaceController.popTemplate`, or a panel via `CPMapTemplate.popPanelWithCompletion`. + private func popTopEntry(animated: Bool) async throws -> String? { + // Ensure at least one entry (the root) remains + guard navigationStack.count > 1, let top = navigationStack.last else { return nil } - try await interfaceController.popTemplate( - animated: animated - ) + switch top { + case .template(let templateId): + try await interfaceController.popTemplate(animated: animated) - try RootModule.withTemplateStore { templateStore in - templateStore.removeTemplate(templateId: templateId) - } + try RootModule.withTemplateStore { templateStore in + templateStore.removeTemplate(templateId: templateId) + } + + removeNavigationEntryIfPresent(templateId: templateId) - return templateId + return templateId + + case .panel(let templateId, let mapTemplate): + guard #available(iOS 27.0, *) else { return nil } + + try await mapTemplate.popPanel() + + return templateId + } } func popToRootTemplate( animated: Bool ) async throws -> [String] { - var templateIds: [String] = [] + guard navigationStack.count > 1 else { return [] } - templates.forEach { template in - let templateId = template.id + let entriesToPop = Array(navigationStack.dropFirst()) + let poppedIds = entriesToPop.map { $0.id } - if templateId == rootTemplateId { - return - } - templateIds.append(templateId) + if entriesToPop.contains(where: { + if case .template = $0 { return true } + return false + }) { + try await interfaceController.popToRootTemplate(animated: animated) } - if templateIds.count == 0 { - return templateIds + let panelTemplates = entriesToPop.compactMap({ entry in + if case .panel = entry { return entry.id } + return nil + }) + + if !panelTemplates.isEmpty, #available(iOS 27.0, *) { + for templateId in panelTemplates { + try? RootModule.withAutoPlayTemplate(templateId: templateId) { + (template: AutoPlayTemplate) in + template.onWillDisappear(animated: animated) + } + } + + try await RootModule.withInterfaceController { interfaceController in + let mapTemplate = interfaceController.rootTemplate as? CPMapTemplate + try await mapTemplate?.hidePanel() + } + + for entry in entriesToPop { + // mapTemplate?.hidePanel() does not invoke panelDidHide on AutoPlayMapPanelDelegate so we need to do this manually here, TODO: check on later releases if this is fixed + guard case .panel(let templateId, _) = entry else { continue } + try? RootModule.withAutoPlayTemplate(templateId: templateId) { + (template: AutoPlayTemplate) in + template.onDidDisappear(animated: animated) + } + } } - try await interfaceController.popToRootTemplate( - animated: animated - ) try RootModule.withTemplateStore { templateStore in - templateStore.removeTemplates(templateIds: templateIds) + templateStore.removeTemplates(templateIds: poppedIds) } - return templateIds + navigationStack = Array(navigationStack.prefix(1)) + + return poppedIds } func popToTemplate(templateId: String, animated: Bool) async throws -> [String] { - guard - let template = interfaceController.templates.first( - where: { - templateId == $0.id - }) - else { return [] } - - var templateIds: [String] = interfaceController.templates.map { - template in template.id - } - - if let startIndex = templateIds.firstIndex(where: { - $0 == templateId - }), - let endIndex = templateIds.firstIndex(where: { - $0 == topTemplateId - }) - { - templateIds = Array(templateIds[(startIndex)..