@iternio/react-native-auto-play 0.5.14-beta.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +270 -36
  2. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/AndroidAutoSession.kt +5 -0
  3. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridMapTemplate.kt +9 -2
  4. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/NativeBackdrop.kt +40 -0
  5. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VirtualRenderer.kt +52 -0
  6. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/template/GridTemplate.kt +47 -13
  7. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/template/Parser.kt +69 -15
  8. package/ios/Types.swift +3 -0
  9. package/ios/extensions/UIImage+Toggle.swift +5 -2
  10. package/ios/hybrid/HybridAutoPlay.swift +28 -16
  11. package/ios/hybrid/HybridMapTemplate.swift +22 -5
  12. package/ios/scenes/AutoPlayInterfaceController.swift +261 -72
  13. package/ios/templates/AutoPlayMapPanelDelegate.swift +190 -0
  14. package/ios/templates/AutoPlayTemplate.swift +24 -2
  15. package/ios/templates/GridTemplate.swift +57 -57
  16. package/ios/templates/InformationTemplate.swift +84 -9
  17. package/ios/templates/ListTemplate.swift +66 -11
  18. package/ios/templates/MapTemplate.swift +83 -68
  19. package/ios/templates/MessageTemplate.swift +72 -6
  20. package/ios/templates/Parser.swift +564 -69
  21. package/ios/templates/SearchTemplate.swift +1 -1
  22. package/ios/templates/TemplateStore.swift +5 -15
  23. package/ios/utils/SymbolFont.swift +13 -28
  24. package/jest.js +2 -0
  25. package/lib/Constants.d.ts +4 -0
  26. package/lib/Constants.js +6 -0
  27. package/lib/index.d.ts +1 -0
  28. package/lib/index.js +1 -0
  29. package/lib/index.web.d.ts +73 -0
  30. package/lib/index.web.js +72 -1
  31. package/lib/specs/MapTemplate.nitro.d.ts +3 -1
  32. package/lib/templates/GridTemplate.d.ts +23 -3
  33. package/lib/templates/GridTemplate.js +8 -0
  34. package/lib/templates/InformationTemplate.d.ts +38 -12
  35. package/lib/templates/ListTemplate.d.ts +16 -47
  36. package/lib/templates/MapTemplate.d.ts +26 -2
  37. package/lib/templates/MapTemplate.js +16 -2
  38. package/lib/templates/MessageTemplate.d.ts +52 -11
  39. package/lib/templates/MessageTemplate.js +1 -1
  40. package/lib/types/Image.d.ts +6 -1
  41. package/lib/utils/NitroColor.d.ts +1 -0
  42. package/lib/utils/NitroColor.js +8 -0
  43. package/lib/utils/NitroImage.js +1 -1
  44. package/lib/utils/NitroManeuver.js +1 -3
  45. package/lib/utils/NitroOptionsPanel.d.ts +103 -0
  46. package/lib/utils/NitroOptionsPanel.js +63 -0
  47. package/lib/utils/NitroSection.d.ts +89 -2
  48. package/lib/utils/NitroSection.js +49 -2
  49. package/lib-cjs/AutoPlayHeadlessJsTask.js +16 -0
  50. package/lib-cjs/Constants.js +9 -0
  51. package/lib-cjs/components/MapTemplateContext.js +10 -0
  52. package/lib-cjs/components/SafeAreaInsetsContext.js +21 -0
  53. package/lib-cjs/components/SafeAreaView.js +22 -0
  54. package/lib-cjs/components/WindowInformationWrapper.js +20 -0
  55. package/lib-cjs/hooks/useAndroidAutoTelemetry.js +115 -0
  56. package/lib-cjs/hooks/useFocusedEffect.js +35 -0
  57. package/lib-cjs/hooks/useMapTemplate.js +13 -0
  58. package/lib-cjs/hooks/useSafeAreaInsets.js +9 -0
  59. package/lib-cjs/hooks/useVoiceInput.js +42 -0
  60. package/lib-cjs/hybrid/HybridAndroidAutoTelemetry.js +4 -0
  61. package/lib-cjs/hybrid/HybridAndroidWindowInformation.js +4 -0
  62. package/lib-cjs/hybrid/HybridAutoPlay.js +5 -0
  63. package/lib-cjs/hybrid/HybridVoice.js +61 -0
  64. package/lib-cjs/index.js +76 -0
  65. package/lib-cjs/index.web.js +98 -0
  66. package/lib-cjs/scenes/AutoPlayCluster.js +140 -0
  67. package/lib-cjs/scenes/CarPlayDashboardScene.js +100 -0
  68. package/lib-cjs/specs/AndroidAutoTelemetry.nitro.js +2 -0
  69. package/lib-cjs/specs/AndroidAutomotive.nitro.js +16 -0
  70. package/lib-cjs/specs/AndroidWindowInformation.nitro.js +2 -0
  71. package/lib-cjs/specs/AutoPlay.nitro.js +2 -0
  72. package/lib-cjs/specs/CarPlayDashboard.nitro.js +2 -0
  73. package/lib-cjs/specs/Cluster.nitro.js +2 -0
  74. package/lib-cjs/specs/GridTemplate.nitro.js +2 -0
  75. package/lib-cjs/specs/InformationTemplate.nitro.js +2 -0
  76. package/lib-cjs/specs/ListTemplate.nitro.js +2 -0
  77. package/lib-cjs/specs/MapTemplate.nitro.js +2 -0
  78. package/lib-cjs/specs/MessageTemplate.nitro.js +2 -0
  79. package/lib-cjs/specs/SearchTemplate.nitro.js +2 -0
  80. package/lib-cjs/specs/SignInTemplate.nitro.js +2 -0
  81. package/lib-cjs/specs/Voice.nitro.js +2 -0
  82. package/lib-cjs/templates/GridTemplate.js +41 -0
  83. package/lib-cjs/templates/InformationTemplate.js +49 -0
  84. package/lib-cjs/templates/ListTemplate.js +33 -0
  85. package/lib-cjs/templates/MapTemplate.js +168 -0
  86. package/lib-cjs/templates/MessageTemplate.js +50 -0
  87. package/lib-cjs/templates/SearchTemplate.js +32 -0
  88. package/lib-cjs/templates/SignInTemplate.js +55 -0
  89. package/lib-cjs/templates/Template.js +40 -0
  90. package/lib-cjs/types/Button.js +12 -0
  91. package/lib-cjs/types/Event.js +2 -0
  92. package/lib-cjs/types/Image.js +2 -0
  93. package/lib-cjs/types/Maneuver.js +77 -0
  94. package/lib-cjs/types/RootComponent.js +2 -0
  95. package/lib-cjs/types/SignInMethod.js +22 -0
  96. package/lib-cjs/types/Telemetry.js +24 -0
  97. package/lib-cjs/types/Text.js +8 -0
  98. package/lib-cjs/types/Trip.js +2 -0
  99. package/lib-cjs/types/Voice.js +2 -0
  100. package/lib-cjs/utils/ErrorUtil.js +34 -0
  101. package/lib-cjs/utils/NitroAction.js +129 -0
  102. package/lib-cjs/utils/NitroAlert.js +19 -0
  103. package/lib-cjs/utils/NitroAttributedString.js +14 -0
  104. package/lib-cjs/utils/NitroColor.js +32 -0
  105. package/lib-cjs/utils/NitroGrid.js +12 -0
  106. package/lib-cjs/utils/NitroImage.js +97 -0
  107. package/lib-cjs/utils/NitroManeuver.js +79 -0
  108. package/lib-cjs/utils/NitroMapButton.js +33 -0
  109. package/lib-cjs/utils/NitroOptionsPanel.js +66 -0
  110. package/lib-cjs/utils/NitroSection.js +112 -0
  111. package/nitrogen/generated/android/ReactNativeAutoPlay+autolinking.cmake +1 -0
  112. package/nitrogen/generated/android/c++/JChargingConnector.hpp +79 -0
  113. package/nitrogen/generated/android/c++/JGridImageSize.hpp +64 -0
  114. package/nitrogen/generated/android/c++/JGridTemplateConfig.hpp +7 -1
  115. package/nitrogen/generated/android/c++/JHybridGridTemplateSpec.cpp +4 -0
  116. package/nitrogen/generated/android/c++/JHybridInformationTemplateSpec.cpp +12 -0
  117. package/nitrogen/generated/android/c++/JHybridListTemplateSpec.cpp +12 -0
  118. package/nitrogen/generated/android/c++/JHybridMapTemplateSpec.cpp +78 -3
  119. package/nitrogen/generated/android/c++/JHybridMapTemplateSpec.hpp +2 -1
  120. package/nitrogen/generated/android/c++/JHybridSearchTemplateSpec.cpp +12 -0
  121. package/nitrogen/generated/android/c++/JInformationTemplateConfig.hpp +6 -0
  122. package/nitrogen/generated/android/c++/JListImageType.hpp +67 -0
  123. package/nitrogen/generated/android/c++/JListTemplateConfig.hpp +6 -0
  124. package/nitrogen/generated/android/c++/JNitroChargerLocation.hpp +115 -0
  125. package/nitrogen/generated/android/c++/JNitroChargerOutlet.hpp +82 -0
  126. package/nitrogen/generated/android/c++/JNitroColor.hpp +8 -4
  127. package/nitrogen/generated/android/c++/JNitroOptionsPanelChargerSection.hpp +112 -0
  128. package/nitrogen/generated/android/c++/JNitroOptionsPanelConfig.hpp +157 -0
  129. package/nitrogen/generated/android/c++/JNitroOptionsPanelGridSection.hpp +102 -0
  130. package/nitrogen/generated/android/c++/JNitroOptionsPanelSection.cpp +30 -0
  131. package/nitrogen/generated/android/c++/JNitroOptionsPanelSection.hpp +129 -0
  132. package/nitrogen/generated/android/c++/JNitroRow.hpp +33 -3
  133. package/nitrogen/generated/android/c++/JNitroSection.hpp +6 -0
  134. package/nitrogen/generated/android/c++/JSearchTemplateConfig.hpp +6 -0
  135. package/nitrogen/generated/android/c++/JWaypointCoordinate.hpp +65 -0
  136. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/ChargingConnector.kt +30 -0
  137. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/GridImageSize.kt +25 -0
  138. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/GridTemplateConfig.kt +9 -4
  139. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridMapTemplateSpec.kt +5 -1
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/ListImageType.kt +26 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroChargerLocation.kt +90 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroChargerOutlet.kt +70 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroColor.kt +9 -4
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelChargerSection.kt +61 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelConfig.kt +61 -0
  146. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelGridSection.kt +56 -0
  147. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelSection.kt +75 -0
  148. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroRow.kt +36 -6
  149. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/WaypointCoordinate.kt +61 -0
  150. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.hpp +174 -0
  151. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Umbrella.hpp +27 -0
  152. package/nitrogen/generated/ios/c++/HybridGridTemplateSpecSwift.hpp +3 -0
  153. package/nitrogen/generated/ios/c++/HybridInformationTemplateSpecSwift.hpp +9 -0
  154. package/nitrogen/generated/ios/c++/HybridListTemplateSpecSwift.hpp +9 -0
  155. package/nitrogen/generated/ios/c++/HybridMapTemplateSpecSwift.hpp +47 -1
  156. package/nitrogen/generated/ios/c++/HybridSearchTemplateSpecSwift.hpp +9 -0
  157. package/nitrogen/generated/ios/swift/ChargingConnector.swift +68 -0
  158. package/nitrogen/generated/ios/swift/GridImageSize.swift +48 -0
  159. package/nitrogen/generated/ios/swift/GridTemplateConfig.swift +12 -1
  160. package/nitrogen/generated/ios/swift/HybridMapTemplateSpec.swift +2 -1
  161. package/nitrogen/generated/ios/swift/HybridMapTemplateSpec_cxx.swift +31 -4
  162. package/nitrogen/generated/ios/swift/ListImageType.swift +52 -0
  163. package/nitrogen/generated/ios/swift/NitroChargerLocation.swift +161 -0
  164. package/nitrogen/generated/ios/swift/NitroChargerOutlet.swift +65 -0
  165. package/nitrogen/generated/ios/swift/NitroColor.swift +20 -2
  166. package/nitrogen/generated/ios/swift/NitroOptionsPanelChargerSection.swift +64 -0
  167. package/nitrogen/generated/ios/swift/NitroOptionsPanelConfig.swift +94 -0
  168. package/nitrogen/generated/ios/swift/NitroOptionsPanelGridSection.swift +53 -0
  169. package/nitrogen/generated/ios/swift/NitroOptionsPanelSection.swift +32 -0
  170. package/nitrogen/generated/ios/swift/NitroRow.swift +81 -1
  171. package/nitrogen/generated/ios/swift/WaypointCoordinate.swift +52 -0
  172. package/nitrogen/generated/shared/c++/ChargingConnector.hpp +104 -0
  173. package/nitrogen/generated/shared/c++/GridImageSize.hpp +84 -0
  174. package/nitrogen/generated/shared/c++/GridTemplateConfig.hpp +8 -1
  175. package/nitrogen/generated/shared/c++/HybridMapTemplateSpec.cpp +1 -0
  176. package/nitrogen/generated/shared/c++/HybridMapTemplateSpec.hpp +5 -1
  177. package/nitrogen/generated/shared/c++/ListImageType.hpp +88 -0
  178. package/nitrogen/generated/shared/c++/NitroChargerLocation.hpp +131 -0
  179. package/nitrogen/generated/shared/c++/NitroChargerOutlet.hpp +98 -0
  180. package/nitrogen/generated/shared/c++/NitroColor.hpp +7 -3
  181. package/nitrogen/generated/shared/c++/NitroOptionsPanelChargerSection.hpp +98 -0
  182. package/nitrogen/generated/shared/c++/NitroOptionsPanelConfig.hpp +107 -0
  183. package/nitrogen/generated/shared/c++/NitroOptionsPanelGridSection.hpp +91 -0
  184. package/nitrogen/generated/shared/c++/NitroRow.hpp +39 -2
  185. package/nitrogen/generated/shared/c++/WaypointCoordinate.hpp +91 -0
  186. package/package.json +7 -3
  187. package/src/Constants.ts +9 -0
  188. package/src/index.ts +1 -0
  189. package/src/index.web.ts +166 -0
  190. package/src/specs/MapTemplate.nitro.ts +3 -1
  191. package/src/templates/GridTemplate.ts +38 -3
  192. package/src/templates/InformationTemplate.ts +60 -29
  193. package/src/templates/ListTemplate.ts +27 -55
  194. package/src/templates/MapTemplate.ts +60 -2
  195. package/src/templates/MessageTemplate.ts +71 -24
  196. package/src/types/Image.ts +6 -1
  197. package/src/utils/NitroColor.ts +10 -1
  198. package/src/utils/NitroImage.ts +1 -5
  199. package/src/utils/NitroManeuver.ts +1 -4
  200. package/src/utils/NitroOptionsPanel.ts +218 -0
  201. package/src/utils/NitroSection.ts +160 -5
@@ -11,6 +11,31 @@ import CarPlay
11
11
  class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
12
12
  let interfaceController: CPInterfaceController
13
13
 
14
+ /// `CPMapTemplate` exposes no way to inspect its panel stack (only push/pop operations),
15
+ /// so this mirrors the combined logical navigation stack of regular pushed templates and
16
+ /// panels pushed onto a `CPMapTemplate`, in push order, to know what `popTemplate` etc.
17
+ /// need to pop next.
18
+ private enum NavigationEntry {
19
+ case template(id: String)
20
+ case panel(id: String, mapTemplate: CPMapTemplate)
21
+
22
+ var id: String {
23
+ switch self {
24
+ case .template(let id): return id
25
+ case .panel(let id, _): return id
26
+ }
27
+ }
28
+ }
29
+
30
+ private var navigationStack: [NavigationEntry] = []
31
+
32
+ /// Set by `templateWillDisappear` whenever root is covered while a panel is visible, and
33
+ /// consulted by `templateWillAppear` to know which panel to reveal — inferring it from
34
+ /// `navigationStack` position only works when the coverer is a pushed template (tracked in
35
+ /// the stack); a presented template (e.g. a `MessageTemplate` without `mapConfig`) never
36
+ /// touches `navigationStack` at all, so position-based inference silently breaks for it.
37
+ private var coveredPanelId: String?
38
+
14
39
  init(
15
40
  interfaceController: CPInterfaceController
16
41
  ) {
@@ -29,18 +54,10 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
29
54
  interfaceController.rootTemplate
30
55
  }
31
56
 
32
- var topTemplate: CPTemplate? {
33
- interfaceController.topTemplate
34
- }
35
-
36
57
  var templates: [CPTemplate] {
37
58
  interfaceController.templates
38
59
  }
39
60
 
40
- var topTemplateId: String? {
41
- return interfaceController.topTemplate?.id
42
- }
43
-
44
61
  var rootTemplateId: String? {
45
62
  return interfaceController.rootTemplate.id
46
63
  }
@@ -49,99 +66,240 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
49
66
  _ templateToPush: CPTemplate,
50
67
  animated: Bool
51
68
  ) async throws -> Bool {
52
- return try await interfaceController.pushTemplate(
69
+ let result = try await interfaceController.pushTemplate(
53
70
  templateToPush,
54
71
  animated: animated
55
72
  )
73
+
74
+ navigationStack.append(.template(id: templateToPush.id))
75
+
76
+ return result
77
+ }
78
+
79
+ /// Pushes `panel` onto the current root template, which must already be a `CPMapTemplate`
80
+ /// (e.g. a `MapTemplate` previously set via `setRootTemplate`).
81
+ @available(iOS 27.0, *)
82
+ func pushPanel(
83
+ _ panel: CPMapPanel,
84
+ templateId: String
85
+ ) async throws {
86
+ guard let mapTemplate = rootTemplate as? CPMapTemplate else {
87
+ throw AutoPlayError.rootTemplateNotMapTemplate(
88
+ "\(templateId) has mapConfig set, which requires the root template to be a CPMapTemplate"
89
+ )
90
+ }
91
+
92
+ // A regular pushed template covers the map (and everything on it, panels included) on
93
+ // screen, but pushPanel only operates on the map's own, separate panel stack — pushing
94
+ // onto it here would silently attach to a hidden surface with no visible effect and no
95
+ // lifecycle callbacks, so this must be rejected rather than desyncing navigationStack.
96
+ guard
97
+ !navigationStack.contains(where: {
98
+ if case .template(let id) = $0 { return id != rootTemplate.id }
99
+ return false
100
+ })
101
+ else {
102
+ throw AutoPlayError.mapTemplateNotVisible(
103
+ "\(templateId) has mapConfig set, but the map template is currently covered by another pushed template"
104
+ )
105
+ }
106
+
107
+ // CarPlay rejects pushPanel outright while a navigation alert is showing
108
+ // (CPMapTemplatePanelErrorDomain code 4) — dismiss it first, same as showAlert already
109
+ // does when replacing one alert with another.
110
+ if mapTemplate.currentNavigationAlert != nil {
111
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
112
+ mapTemplate.dismissNavigationAlert(animated: true) { _ in
113
+ continuation.resume()
114
+ }
115
+ }
116
+ }
117
+
118
+ try await withCheckedThrowingContinuation {
119
+ (continuation: CheckedContinuation<Void, Error>) in
120
+ mapTemplate.pushPanel(panel) { _, error in
121
+ if let error {
122
+ continuation.resume(throwing: error)
123
+ return
124
+ }
125
+ continuation.resume()
126
+ }
127
+ }
128
+
129
+ navigationStack.append(.panel(id: templateId, mapTemplate: mapTemplate))
56
130
  }
57
131
 
58
132
  func setRootTemplate(
59
133
  _ rootTemplate: CPTemplate,
60
134
  animated: Bool
61
135
  ) async throws -> Bool {
62
- return try await interfaceController.setRootTemplate(
136
+ let result = try await interfaceController.setRootTemplate(
63
137
  rootTemplate,
64
138
  animated: animated
65
139
  )
140
+
141
+ let previousEntries = navigationStack
142
+ navigationStack = [.template(id: rootTemplate.id)]
143
+
144
+ // No CarPlay callback tears down a panel when its root is replaced, unlike templateDidDisappear for regular templates.
145
+ if #available(iOS 27.0, *) {
146
+ let panelIds = previousEntries.compactMap { entry -> String? in
147
+ if case .panel(let id, _) = entry { return id }
148
+ return nil
149
+ }
150
+
151
+ if !panelIds.isEmpty {
152
+ // Only the topmost entry could still be visible; others already got their disappear pair when covered.
153
+ if let last = previousEntries.last, case .panel(let visiblePanelId, _) = last {
154
+ try? RootModule.withAutoPlayTemplate(templateId: visiblePanelId) {
155
+ (template: AutoPlayTemplate) in
156
+ template.onWillDisappear(animated: animated)
157
+ template.onDidDisappear(animated: animated)
158
+ }
159
+ }
160
+
161
+ try RootModule.withTemplateStore { templateStore in
162
+ templateStore.removeTemplates(templateIds: panelIds)
163
+ }
164
+ panelIds.forEach { HybridAutoPlay.removeListeners(templateId: $0) }
165
+ }
166
+ }
167
+
168
+ return result
169
+ }
170
+
171
+ /// Whether `templateId` is the top entry of the combined template/panel navigation stack.
172
+ func isTopEntry(templateId: String) -> Bool {
173
+ return navigationStack.last?.id == templateId
174
+ }
175
+
176
+ /// Removes the navigation-stack entry for `templateId` if present; idempotent if it's
177
+ /// already gone. For `.template` entries, `popTopEntry` removes the entry directly for
178
+ /// app-initiated pops, and `templateDidDisappear` calls this for CarPlay-initiated pops
179
+ /// (e.g. its built-in back button) that `popTopEntry` never sees. For `.panel` entries,
180
+ /// `popTopEntry` doesn't touch `navigationStack` at all — `CPMapPanelDelegate.panelDidHide`
181
+ /// calling this is the only place a panel entry is removed, covering both app-initiated
182
+ /// and CarPlay-initiated pops alike.
183
+ func removeNavigationEntryIfPresent(templateId: String) {
184
+ navigationStack.removeAll { $0.id == templateId }
185
+ }
186
+
187
+ /// Ids of all panel entries currently in the navigation stack, in push order. `navigationStack`
188
+ /// itself is private, so this is the minimal data `CPMapPanelDelegate` callbacks need to
189
+ /// simulate cover/reveal lifecycle events themselves — `CPMapPanelDelegate` has no
190
+ /// equivalent of `CPInterfaceControllerDelegate` telling a covered/revealed panel about it.
191
+ @available(iOS 27.0, *)
192
+ var panelTemplateIds: [String] {
193
+ navigationStack.compactMap { entry in
194
+ if case .panel(let id, _) = entry { return id }
195
+ return nil
196
+ }
197
+ }
198
+
199
+ /// Whether any panel exists in the stack, even if currently covered by a regular template.
200
+ @available(iOS 27.0, *)
201
+ var hasPanel: Bool {
202
+ !panelTemplateIds.isEmpty
66
203
  }
67
204
 
68
205
  func popTemplate(
69
206
  animated: Bool
70
207
  ) async throws -> String? {
71
- guard let templateId = topTemplateId else { return nil }
208
+ return try await popTopEntry(animated: animated)
209
+ }
72
210
 
73
- // Ensure at least one template remains
74
- guard templates.count > 1 else { return nil }
211
+ /// Pops whatever is on top of the combined navigation stack: a regular pushed template via
212
+ /// `CPInterfaceController.popTemplate`, or a panel via `CPMapTemplate.popPanelWithCompletion`.
213
+ private func popTopEntry(animated: Bool) async throws -> String? {
214
+ // Ensure at least one entry (the root) remains
215
+ guard navigationStack.count > 1, let top = navigationStack.last else { return nil }
75
216
 
76
- try await interfaceController.popTemplate(
77
- animated: animated
78
- )
217
+ switch top {
218
+ case .template(let templateId):
219
+ try await interfaceController.popTemplate(animated: animated)
79
220
 
80
- try RootModule.withTemplateStore { templateStore in
81
- templateStore.removeTemplate(templateId: templateId)
82
- }
221
+ try RootModule.withTemplateStore { templateStore in
222
+ templateStore.removeTemplate(templateId: templateId)
223
+ }
224
+
225
+ removeNavigationEntryIfPresent(templateId: templateId)
226
+
227
+ return templateId
83
228
 
84
- return templateId
229
+ case .panel(let templateId, let mapTemplate):
230
+ guard #available(iOS 27.0, *) else { return nil }
231
+
232
+ try await mapTemplate.popPanel()
233
+
234
+ // popPanel() never triggers panelDidHide (only hidePanel() does), so this has to
235
+ // do the teardown + reveal itself rather than rely on the delegate.
236
+ await handlePanelPopped(templateId: templateId, animated: animated)
237
+
238
+ return templateId
239
+ }
85
240
  }
86
241
 
87
242
  func popToRootTemplate(
88
243
  animated: Bool
89
244
  ) async throws -> [String] {
90
- var templateIds: [String] = []
245
+ guard navigationStack.count > 1 else { return [] }
91
246
 
92
- templates.forEach { template in
93
- let templateId = template.id
247
+ let entriesToPop = Array(navigationStack.dropFirst())
248
+ let poppedIds = entriesToPop.map { $0.id }
94
249
 
95
- if templateId == rootTemplateId {
96
- return
97
- }
98
- templateIds.append(templateId)
250
+ if entriesToPop.contains(where: {
251
+ if case .template = $0 { return true }
252
+ return false
253
+ }) {
254
+ try await interfaceController.popToRootTemplate(animated: animated)
99
255
  }
100
256
 
101
- if templateIds.count == 0 {
102
- return templateIds
103
- }
104
- try await interfaceController.popToRootTemplate(
105
- animated: animated
106
- )
257
+ let panelTemplates = entriesToPop.compactMap({ entry in
258
+ if case .panel = entry { return entry.id }
259
+ return nil
260
+ })
261
+
262
+ // The visible panel is left for panelDidHide to tear down (via hidePanel() below) so its
263
+ // lifecycle + button reclaim isn't duplicated here; only the already-covered ones (which
264
+ // already got their disappear pair) need removing now.
265
+ let visiblePanelId = panelTemplates.last
266
+ let idsToRemoveNow = poppedIds.filter { $0 != visiblePanelId }
107
267
 
108
268
  try RootModule.withTemplateStore { templateStore in
109
- templateStore.removeTemplates(templateIds: templateIds)
269
+ templateStore.removeTemplates(templateIds: idsToRemoveNow)
270
+ }
271
+
272
+ // Keep the visible panel tracked so panelDidHide still finds it there.
273
+ let keptEntry = visiblePanelId.flatMap { id in entriesToPop.first { $0.id == id } }
274
+ navigationStack = [navigationStack[0]] + (keptEntry.map { [$0] } ?? [])
275
+
276
+ if visiblePanelId != nil, #available(iOS 27.0, *) {
277
+ try await RootModule.withInterfaceController { interfaceController in
278
+ let mapTemplate = interfaceController.rootTemplate as? CPMapTemplate
279
+ try await mapTemplate?.hidePanel()
280
+ }
110
281
  }
111
282
 
112
- return templateIds
283
+ return poppedIds
113
284
  }
114
285
 
115
286
  func popToTemplate(templateId: String, animated: Bool) async throws
116
287
  -> [String]
117
288
  {
118
- guard
119
- let template = interfaceController.templates.first(
120
- where: {
121
- templateId == $0.id
122
- })
123
- else { return [] }
124
-
125
- var templateIds: [String] = interfaceController.templates.map {
126
- template in template.id
289
+ guard navigationStack.contains(where: { $0.id == templateId }) else {
290
+ return []
127
291
  }
128
292
 
129
- if let startIndex = templateIds.firstIndex(where: {
130
- $0 == templateId
131
- }),
132
- let endIndex = templateIds.firstIndex(where: {
133
- $0 == topTemplateId
134
- })
135
- {
136
- templateIds = Array(templateIds[(startIndex)..<endIndex])
137
- }
293
+ var poppedIds: [String] = []
138
294
 
139
- try await interfaceController.pop(
140
- to: template,
141
- animated: animated
142
- )
295
+ while navigationStack.last?.id != templateId {
296
+ guard let poppedId = try await popTopEntry(animated: animated) else {
297
+ break
298
+ }
299
+ poppedIds.append(poppedId)
300
+ }
143
301
 
144
- return templateIds
302
+ return poppedIds
145
303
  }
146
304
 
147
305
  func presentTemplate(
@@ -181,6 +339,21 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
181
339
  animated: animated
182
340
  )
183
341
  }
342
+
343
+ // Panels only attach to root, so only relevant when root reappears. `coveredPanelId` is
344
+ // set by templateWillDisappear when root was covered — this can't be inferred from
345
+ // navigationStack position, since a presented (not pushed) covering template, like a
346
+ // MessageTemplate without mapConfig, never touches navigationStack at all.
347
+ if #available(iOS 27.0, *), templateId == rootTemplateId {
348
+ if let panelId = coveredPanelId, panelTemplateIds.contains(panelId) {
349
+ try? RootModule.withAutoPlayTemplate(templateId: panelId) {
350
+ (template: AutoPlayTemplate) in
351
+ template.onWillAppear(animated: animated)
352
+ template.onDidAppear(animated: animated)
353
+ }
354
+ }
355
+ coveredPanelId = nil
356
+ }
184
357
  }
185
358
 
186
359
  func templateDidAppear(
@@ -189,13 +362,6 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
189
362
  ) {
190
363
  let templateId = aTemplate.id
191
364
 
192
- if rootTemplateId == templateId {
193
- // this makes sure we purge outdated CPSearchTemplate since that one can be popped on with a CarPlay native button we can not intercept
194
- try? RootModule.withTemplateStore { templateStore in
195
- templateStore.purge()
196
- }
197
- }
198
-
199
365
  try? RootModule.withAutoPlayTemplate(
200
366
  templateId: templateId,
201
367
  perform: { (template: AutoPlayTemplate) in
@@ -222,6 +388,22 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
222
388
  )
223
389
  }
224
390
  )
391
+
392
+ // Panels only attach to root, so only relevant when root is covered — by a push (not yet
393
+ // appended to navigationStack at this point, so .last is still the panel being covered)
394
+ // or by presenting a template (e.g. a MessageTemplate without mapConfig, which never
395
+ // touches navigationStack at all, so .last is still the panel either way).
396
+ if #available(iOS 27.0, *), templateId == rootTemplateId,
397
+ let last = navigationStack.last,
398
+ case .panel(let panelId, _) = last
399
+ {
400
+ coveredPanelId = panelId
401
+ try? RootModule.withAutoPlayTemplate(templateId: panelId) {
402
+ (template: AutoPlayTemplate) in
403
+ template.onWillDisappear(animated: animated)
404
+ template.onDidDisappear(animated: animated)
405
+ }
406
+ }
225
407
  }
226
408
 
227
409
  func templateDidDisappear(
@@ -239,14 +421,21 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
239
421
  }
240
422
  )
241
423
 
242
- if aTemplate is CPAlertTemplate {
243
- try? RootModule.withTemplateStore { templateStore in
244
- templateStore.removeTemplate(templateId: templateId)
245
- }
424
+ /// this makes sure onPopped for templates is only invoked when they are gone forever
425
+ /// in case the template is just hidden by some other template it will not fire
426
+ /// since CPAlertTemplate is presented and nothing can be pushed on top of it there is no special handling required
427
+ guard !interfaceController.templates.contains(where: { $0.id == templateId }) else {
428
+ return
429
+ }
246
430
 
247
- HybridAutoPlay.removeListeners(
248
- templateId: templateId
249
- )
431
+ removeNavigationEntryIfPresent(templateId: templateId)
432
+
433
+ try? RootModule.withTemplateStore { templateStore in
434
+ templateStore.removeTemplate(templateId: templateId)
250
435
  }
436
+
437
+ HybridAutoPlay.removeListeners(
438
+ templateId: templateId
439
+ )
251
440
  }
252
441
  }
@@ -0,0 +1,190 @@
1
+ //
2
+ // AutoPlayMapPanelDelegate.swift
3
+ // Pods
4
+ //
5
+
6
+ import CarPlay
7
+
8
+ /// Shared `CPMapPanel.Delegate` for any `AutoPlayTemplate` that renders as a map panel
9
+ /// (`ListTemplate`, and future panel-backed templates like a grid). `CPMapPanel.delegate` is
10
+ /// `weak`, so the owning template must keep a strong reference to this instance for as long as
11
+ /// the panel exists.
12
+ @available(iOS 27.0, *)
13
+ class AutoPlayMapPanelDelegate: NSObject, CPMapPanel.Delegate {
14
+ private weak var template: AutoPlayTemplate?
15
+ private let templateId: String
16
+ // As of iOS 27 beta 4, CarPlay invokes panelDidShow twice for a single push. Guard against
17
+ // firing the JS appear callbacks more than once per panel.
18
+ private var hasFiredPanelDidShow = false
19
+
20
+ init(template: AutoPlayTemplate, templateId: String) {
21
+ self.template = template
22
+ self.templateId = templateId
23
+ }
24
+
25
+ /// There's no "will appear" delegate method, so `onWillAppear` and `onDidAppear` fire
26
+ /// together here. Also fires `onWillDisappear` and `onDidDisappear` on whichever panel was on top
27
+ /// before this one, since CPMapPanelDelegate never notifies a covered panel itself —
28
+ /// `panelDidShow` only tells us about the panel that just appeared.
29
+ func panelDidShow(_ panel: CPMapPanel) {
30
+ if hasFiredPanelDidShow {
31
+ return
32
+ }
33
+ hasFiredPanelDidShow = true
34
+
35
+ template?.onWillAppear(animated: true)
36
+ template?.onDidAppear(animated: true)
37
+
38
+ let templateId = self.templateId
39
+
40
+ Task { @MainActor in
41
+ var previousPanelId: String?
42
+ var mapTemplate: CPMapTemplate?
43
+
44
+ try? await RootModule.withInterfaceController { interfaceController in
45
+ mapTemplate = interfaceController.rootTemplate as? CPMapTemplate
46
+ previousPanelId =
47
+ interfaceController.panelTemplateIds
48
+ .filter { $0 != templateId }
49
+ .last
50
+ }
51
+
52
+ // This panel owns the map template's bar buttons and map buttons while it's on top.
53
+ if let mapTemplate {
54
+ // Auto-hiding the bar shifts the panel and interrupts taps on it.
55
+ mapTemplate.automaticallyHidesNavigationBar = false
56
+ mapTemplate.hidesButtonsWithNavigationBar = false
57
+
58
+ applyPanelHeaderActions(self.template?.getPanelHeaderActions(), to: mapTemplate)
59
+ applyPanelMapButtons(self.template?.getPanelMapButtons(), to: mapTemplate)
60
+ }
61
+
62
+ guard let previousPanelId else { return }
63
+
64
+ try? RootModule.withAutoPlayTemplate(templateId: previousPanelId) {
65
+ (template: AutoPlayTemplate) in
66
+ // let the now covered panel know it disappeared
67
+ template.onWillDisappear(animated: true)
68
+ template.onDidDisappear(animated: true)
69
+ }
70
+ }
71
+ }
72
+
73
+ /// There's no "will disappear" delegate method, so `onWillDisappear` and `onDidDisappear`
74
+ /// fire together here. Also fires `onWillAppear` and `onDidAppear` on whichever panel is now on
75
+ /// top after this one is removed, since CPMapPanelDelegate never notifies a revealed panel
76
+ /// itself — `panelDidHide` only tells us about the panel that just disappeared.
77
+ /// https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-27-release-notes#CarPlay
78
+ func panelDidHide(_ panel: CPMapPanel) {
79
+ let templateId = self.templateId
80
+
81
+ Task { @MainActor in
82
+ var stillTracked = false
83
+
84
+ try? await RootModule.withInterfaceController { interfaceController in
85
+ stillTracked = interfaceController.panelTemplateIds.contains(templateId)
86
+ }
87
+
88
+ // Already torn down elsewhere, e.g. by popToRootTemplate for a covered panel.
89
+ guard stillTracked else { return }
90
+
91
+ await handlePanelPopped(templateId: templateId, animated: true)
92
+ }
93
+ }
94
+ }
95
+
96
+ /// Tears down `templateId`'s panel and reveals whatever's now on top (or lets the map template
97
+ /// reclaim its own bar/map buttons if nothing remains). Shared by `panelDidHide` — which only
98
+ /// fires for a `hidePanel()`-triggered close — and `popTopEntry`'s `.panel` case, since
99
+ /// `popPanel()` never triggers `panelDidHide` and so has to run this itself.
100
+ @available(iOS 27.0, *)
101
+ @MainActor
102
+ func handlePanelPopped(templateId: String, animated: Bool) async {
103
+ var revealedPanelId: String?
104
+ var mapTemplate: CPMapTemplate?
105
+
106
+ try? await RootModule.withInterfaceController { interfaceController in
107
+ interfaceController.removeNavigationEntryIfPresent(templateId: templateId)
108
+ revealedPanelId = interfaceController.panelTemplateIds.last
109
+ mapTemplate = interfaceController.rootTemplate as? CPMapTemplate
110
+ }
111
+
112
+ try? RootModule.withAutoPlayTemplate(templateId: templateId) {
113
+ (template: AutoPlayTemplate) in
114
+ template.onWillDisappear(animated: animated)
115
+ template.onDidDisappear(animated: animated)
116
+ }
117
+
118
+ try? RootModule.withTemplateStore { templateStore in
119
+ templateStore.removeTemplate(templateId: templateId)
120
+ }
121
+
122
+ HybridAutoPlay.removeListeners(templateId: templateId)
123
+
124
+ if let revealedPanelId {
125
+ // Another panel is now on top: it reclaims the bar/map buttons and its own appear pair, same as a fresh push.
126
+ try? RootModule.withAutoPlayTemplate(templateId: revealedPanelId) {
127
+ (revealed: AutoPlayTemplate) in
128
+ if let mapTemplate {
129
+ applyPanelHeaderActions(revealed.getPanelHeaderActions(), to: mapTemplate)
130
+ applyPanelMapButtons(revealed.getPanelMapButtons(), to: mapTemplate)
131
+ }
132
+ // let the now uncovered panel know it appeared again
133
+ revealed.onWillAppear(animated: animated)
134
+ revealed.onDidAppear(animated: animated)
135
+ }
136
+ }
137
+ else if let mapTemplate {
138
+ // No panel remains: let the map template reclaim its own bar/map buttons.
139
+ try? RootModule.withAutoPlayTemplate(templateId: mapTemplate.id) {
140
+ (root: AutoPlayTemplate) in
141
+ root.invalidate()
142
+ }
143
+ }
144
+ }
145
+
146
+ /// Re-applies `templateId`'s header/map buttons to the root map template's nav bar, but only if
147
+ /// it's currently the topmost (visible) panel. `_invalidate()` (e.g. from `setHeaderActions()`)
148
+ /// runs for covered panels too, and only the topmost panel owns the nav bar — applying
149
+ /// unconditionally would clobber whichever panel is actually on top. A covered panel's own
150
+ /// buttons get reapplied later, when it's revealed again (see `handlePanelPopped`).
151
+ @available(iOS 27.0, *)
152
+ @MainActor
153
+ func reapplyPanelBarIfVisible(_ template: AutoPlayTemplate, templateId: String) {
154
+ guard
155
+ let scene = SceneStore.getRootScene(),
156
+ let interfaceController = scene.interfaceController,
157
+ interfaceController.panelTemplateIds.last == templateId,
158
+ let mapTemplate = interfaceController.rootTemplate as? CPMapTemplate
159
+ else { return }
160
+
161
+ applyPanelHeaderActions(template.getPanelHeaderActions(), to: mapTemplate)
162
+ applyPanelMapButtons(template.getPanelMapButtons(), to: mapTemplate)
163
+ }
164
+
165
+ /// apply header buttons according to mapConfig.headerActions or remove the map provided buttons in case none are specified for the panel
166
+ @available(iOS 27.0, *)
167
+ @MainActor
168
+ private func applyPanelHeaderActions(_ headerActions: [NitroAction]?, to mapTemplate: CPMapTemplate) {
169
+ guard let headerActions else {
170
+ mapTemplate.backButton = nil
171
+ mapTemplate.leadingNavigationBarButtons = []
172
+ mapTemplate.trailingNavigationBarButtons = []
173
+ return
174
+ }
175
+
176
+ setBarButtons(template: mapTemplate, barButtons: headerActions)
177
+ }
178
+
179
+ /// Applies mapConfig.mapButtons to the map template, or clears them if the panel specifies none.
180
+ @available(iOS 27.0, *)
181
+ @MainActor
182
+ private func applyPanelMapButtons(_ mapButtons: [NitroMapButton]?, to mapTemplate: CPMapTemplate) {
183
+ // Only need the root MapTemplate for its onPanButtonPress callback, since a .pan button still controls the same underlying map.
184
+ try? RootModule.withAutoPlayTemplate(templateId: mapTemplate.id) { (root: MapTemplate) in
185
+ mapTemplate.mapButtons = Parser.parseMapButtons(
186
+ mapButtons: mapButtons ?? [],
187
+ onPanButtonPress: root.onPanButtonPress
188
+ )
189
+ }
190
+ }
@@ -10,10 +10,30 @@ import CarPlay
10
10
  class AutoPlayTemplate: NSObject {
11
11
  public private(set) var autoDismissMs: Double?
12
12
 
13
- func getTemplate() -> CPTemplate {
13
+ func getTemplate() throws -> CPTemplate {
14
14
  fatalError("getTemplate not implemented")
15
15
  }
16
16
 
17
+ /// Templates that render as an overlay panel on an existing root `CPMapTemplate`
18
+ /// (iOS 27+, e.g. `ListTemplate` with `mapConfig` set) override this to return their
19
+ /// `CPMapPanel` instead of providing a `CPTemplate` via `getTemplate()`. Returned as `Any`
20
+ /// since `CPMapPanel` is only available on iOS 27+ while this class supports iOS 15.1+.
21
+ func getPanel() -> Any? {
22
+ return nil
23
+ }
24
+
25
+ /// Header actions to apply to the root map template's bar while this template renders as
26
+ /// a map panel (see `getPanel()`) — distinct from any `barButtons` used for a plain
27
+ /// `CPTemplate`, since the panel itself has no nav bar of its own.
28
+ func getPanelHeaderActions() -> [NitroAction]? {
29
+ return nil
30
+ }
31
+
32
+ /// Map buttons to apply to the root map template while this renders as a map panel (see `getPanel()`).
33
+ func getPanelMapButtons() -> [NitroMapButton]? {
34
+ return nil
35
+ }
36
+
17
37
  @MainActor final func invalidate() {
18
38
  if SceneStore.getRootScene() == nil {
19
39
  return
@@ -25,7 +45,9 @@ class AutoPlayTemplate: NSObject {
25
45
  /// Override in subclasses to perform template invalidation.
26
46
  /// Do not call this method directly. Call `invalidate()` instead.
27
47
  @MainActor func _invalidate() {}
28
- @MainActor func traitCollectionDidChange() {}
48
+ @MainActor func traitCollectionDidChange() {
49
+ invalidate()
50
+ }
29
51
 
30
52
  func onWillAppear(animated: Bool) {}
31
53
  func onDidAppear(animated: Bool) {}