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

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 (122) hide show
  1. package/README.md +214 -29
  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 +21 -2
  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/lib/Constants.d.ts +4 -0
  25. package/lib/Constants.js +6 -0
  26. package/lib/index.d.ts +1 -0
  27. package/lib/index.js +1 -0
  28. package/lib/specs/MapTemplate.nitro.d.ts +3 -1
  29. package/lib/templates/GridTemplate.d.ts +23 -3
  30. package/lib/templates/GridTemplate.js +8 -0
  31. package/lib/templates/InformationTemplate.d.ts +38 -12
  32. package/lib/templates/ListTemplate.d.ts +16 -47
  33. package/lib/templates/MapTemplate.d.ts +26 -2
  34. package/lib/templates/MapTemplate.js +16 -2
  35. package/lib/templates/MessageTemplate.d.ts +52 -11
  36. package/lib/templates/MessageTemplate.js +1 -1
  37. package/lib/utils/NitroOptionsPanel.d.ts +103 -0
  38. package/lib/utils/NitroOptionsPanel.js +63 -0
  39. package/lib/utils/NitroSection.d.ts +89 -2
  40. package/lib/utils/NitroSection.js +49 -2
  41. package/nitrogen/generated/android/ReactNativeAutoPlay+autolinking.cmake +1 -0
  42. package/nitrogen/generated/android/c++/JChargingConnector.hpp +79 -0
  43. package/nitrogen/generated/android/c++/JGridImageSize.hpp +64 -0
  44. package/nitrogen/generated/android/c++/JGridTemplateConfig.hpp +7 -1
  45. package/nitrogen/generated/android/c++/JHybridGridTemplateSpec.cpp +4 -0
  46. package/nitrogen/generated/android/c++/JHybridInformationTemplateSpec.cpp +12 -0
  47. package/nitrogen/generated/android/c++/JHybridListTemplateSpec.cpp +12 -0
  48. package/nitrogen/generated/android/c++/JHybridMapTemplateSpec.cpp +78 -3
  49. package/nitrogen/generated/android/c++/JHybridMapTemplateSpec.hpp +2 -1
  50. package/nitrogen/generated/android/c++/JHybridSearchTemplateSpec.cpp +12 -0
  51. package/nitrogen/generated/android/c++/JInformationTemplateConfig.hpp +6 -0
  52. package/nitrogen/generated/android/c++/JListImageType.hpp +67 -0
  53. package/nitrogen/generated/android/c++/JListTemplateConfig.hpp +6 -0
  54. package/nitrogen/generated/android/c++/JNitroChargerLocation.hpp +115 -0
  55. package/nitrogen/generated/android/c++/JNitroChargerOutlet.hpp +82 -0
  56. package/nitrogen/generated/android/c++/JNitroOptionsPanelChargerSection.hpp +112 -0
  57. package/nitrogen/generated/android/c++/JNitroOptionsPanelConfig.hpp +157 -0
  58. package/nitrogen/generated/android/c++/JNitroOptionsPanelGridSection.hpp +102 -0
  59. package/nitrogen/generated/android/c++/JNitroOptionsPanelSection.cpp +30 -0
  60. package/nitrogen/generated/android/c++/JNitroOptionsPanelSection.hpp +129 -0
  61. package/nitrogen/generated/android/c++/JNitroRow.hpp +33 -3
  62. package/nitrogen/generated/android/c++/JNitroSection.hpp +6 -0
  63. package/nitrogen/generated/android/c++/JSearchTemplateConfig.hpp +6 -0
  64. package/nitrogen/generated/android/c++/JWaypointCoordinate.hpp +65 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/ChargingConnector.kt +30 -0
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/GridImageSize.kt +25 -0
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/GridTemplateConfig.kt +9 -4
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridMapTemplateSpec.kt +5 -1
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/ListImageType.kt +26 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroChargerLocation.kt +90 -0
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroChargerOutlet.kt +70 -0
  72. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelChargerSection.kt +61 -0
  73. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelConfig.kt +61 -0
  74. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelGridSection.kt +56 -0
  75. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroOptionsPanelSection.kt +75 -0
  76. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroRow.kt +36 -6
  77. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/WaypointCoordinate.kt +61 -0
  78. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.hpp +174 -0
  79. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Umbrella.hpp +27 -0
  80. package/nitrogen/generated/ios/c++/HybridGridTemplateSpecSwift.hpp +3 -0
  81. package/nitrogen/generated/ios/c++/HybridInformationTemplateSpecSwift.hpp +9 -0
  82. package/nitrogen/generated/ios/c++/HybridListTemplateSpecSwift.hpp +9 -0
  83. package/nitrogen/generated/ios/c++/HybridMapTemplateSpecSwift.hpp +47 -1
  84. package/nitrogen/generated/ios/c++/HybridSearchTemplateSpecSwift.hpp +9 -0
  85. package/nitrogen/generated/ios/swift/ChargingConnector.swift +68 -0
  86. package/nitrogen/generated/ios/swift/GridImageSize.swift +48 -0
  87. package/nitrogen/generated/ios/swift/GridTemplateConfig.swift +12 -1
  88. package/nitrogen/generated/ios/swift/HybridMapTemplateSpec.swift +2 -1
  89. package/nitrogen/generated/ios/swift/HybridMapTemplateSpec_cxx.swift +31 -4
  90. package/nitrogen/generated/ios/swift/ListImageType.swift +52 -0
  91. package/nitrogen/generated/ios/swift/NitroChargerLocation.swift +161 -0
  92. package/nitrogen/generated/ios/swift/NitroChargerOutlet.swift +65 -0
  93. package/nitrogen/generated/ios/swift/NitroOptionsPanelChargerSection.swift +64 -0
  94. package/nitrogen/generated/ios/swift/NitroOptionsPanelConfig.swift +94 -0
  95. package/nitrogen/generated/ios/swift/NitroOptionsPanelGridSection.swift +53 -0
  96. package/nitrogen/generated/ios/swift/NitroOptionsPanelSection.swift +32 -0
  97. package/nitrogen/generated/ios/swift/NitroRow.swift +81 -1
  98. package/nitrogen/generated/ios/swift/WaypointCoordinate.swift +52 -0
  99. package/nitrogen/generated/shared/c++/ChargingConnector.hpp +104 -0
  100. package/nitrogen/generated/shared/c++/GridImageSize.hpp +84 -0
  101. package/nitrogen/generated/shared/c++/GridTemplateConfig.hpp +8 -1
  102. package/nitrogen/generated/shared/c++/HybridMapTemplateSpec.cpp +1 -0
  103. package/nitrogen/generated/shared/c++/HybridMapTemplateSpec.hpp +5 -1
  104. package/nitrogen/generated/shared/c++/ListImageType.hpp +88 -0
  105. package/nitrogen/generated/shared/c++/NitroChargerLocation.hpp +131 -0
  106. package/nitrogen/generated/shared/c++/NitroChargerOutlet.hpp +98 -0
  107. package/nitrogen/generated/shared/c++/NitroOptionsPanelChargerSection.hpp +98 -0
  108. package/nitrogen/generated/shared/c++/NitroOptionsPanelConfig.hpp +107 -0
  109. package/nitrogen/generated/shared/c++/NitroOptionsPanelGridSection.hpp +91 -0
  110. package/nitrogen/generated/shared/c++/NitroRow.hpp +39 -2
  111. package/nitrogen/generated/shared/c++/WaypointCoordinate.hpp +91 -0
  112. package/package.json +1 -1
  113. package/src/Constants.ts +9 -0
  114. package/src/index.ts +1 -0
  115. package/src/specs/MapTemplate.nitro.ts +3 -1
  116. package/src/templates/GridTemplate.ts +38 -3
  117. package/src/templates/InformationTemplate.ts +60 -29
  118. package/src/templates/ListTemplate.ts +27 -55
  119. package/src/templates/MapTemplate.ts +60 -2
  120. package/src/templates/MessageTemplate.ts +71 -24
  121. package/src/utils/NitroOptionsPanel.ts +218 -0
  122. package/src/utils/NitroSection.ts +160 -5
@@ -52,6 +52,7 @@ import com.margelo.nitro.swe.iternio.reactnativeautoplay.ForkType
52
52
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.GlyphImage
53
53
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.KeepType
54
54
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.ListTemplateConfig
55
+ import com.margelo.nitro.swe.iternio.reactnativeautoplay.ListImageType
55
56
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.ManeuverType
56
57
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.NitroAction
57
58
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.NitroActionType
@@ -84,6 +85,10 @@ import kotlin.math.abs
84
85
  import androidx.core.net.toUri
85
86
 
86
87
  object Parser {
88
+ // IMAGE_TYPE_MEDIUM was added to androidx.car.app in 1.8.0. Keep the protocol value here
89
+ // while this library continues to depend on 1.7.0.
90
+ private const val ROW_IMAGE_TYPE_MEDIUM = 16
91
+
87
92
  const val TAG = "Parser"
88
93
 
89
94
  fun parseHeader(
@@ -397,7 +402,13 @@ object Parser {
397
402
  addText(parseText(detailedText))
398
403
  }
399
404
  row.image?.let { image ->
400
- setImage(parseImage(context, image))
405
+ val parsedImage = parseImage(context, image)
406
+ val imageType = row.imageType
407
+ if (imageType == null) {
408
+ setImage(parsedImage)
409
+ } else {
410
+ setImage(parsedImage, imageType.toRowImageType())
411
+ }
401
412
  }
402
413
  row.browsable?.let { browsable ->
403
414
  setBrowsable(browsable)
@@ -426,6 +437,14 @@ object Parser {
426
437
  }.build()
427
438
  }
428
439
 
440
+ private fun ListImageType.toRowImageType(): Int = when (this) {
441
+ ListImageType.LARGE -> Row.IMAGE_TYPE_LARGE
442
+ ListImageType.MEDIUM -> ROW_IMAGE_TYPE_MEDIUM
443
+ ListImageType.SMALL -> Row.IMAGE_TYPE_SMALL
444
+ ListImageType.EXTRA_SMALL -> Row.IMAGE_TYPE_EXTRA_SMALL
445
+ ListImageType.ICON -> Row.IMAGE_TYPE_ICON
446
+ }
447
+
429
448
  fun formatToTimestamp(time: DurationWithTimeZone): String {
430
449
  val calendar = Calendar.getInstance().apply {
431
450
  add(Calendar.SECOND, time.seconds.toInt())
@@ -836,4 +855,4 @@ object Parser {
836
855
  }
837
856
  }.build()
838
857
  }
839
- }
858
+ }
package/ios/Types.swift CHANGED
@@ -20,4 +20,7 @@ enum AutoPlayError: LocalizedError {
20
20
  case noUiWindow(String)
21
21
  case initReactRootViewFailed(String)
22
22
  case voiceInputCancelled
23
+ case rootTemplateNotMapTemplate(String)
24
+ case templateIsMapPanel(String)
25
+ case mapTemplateNotVisible(String)
23
26
  }
@@ -10,9 +10,12 @@ import UIKit
10
10
  extension UIImage {
11
11
  static func makeToggleImage(
12
12
  enabled: Bool,
13
- maximumImageSize: CGSize
13
+ maximumImageSize: CGSize,
14
+ traitCollection: UITraitCollection
14
15
  ) -> UIImage {
15
- let renderer = UIGraphicsImageRenderer(size: maximumImageSize)
16
+ let format = UIGraphicsImageRendererFormat()
17
+ format.scale = traitCollection.displayScale
18
+ let renderer = UIGraphicsImageRenderer(size: maximumImageSize, format: format)
16
19
  return renderer.image { ctx in
17
20
  // Draw the track as a pill, centered vertically
18
21
  let trackHeight = maximumImageSize.height * 0.5
@@ -160,7 +160,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
160
160
  templateId: templateId
161
161
  )
162
162
 
163
- let carPlayTemplate = template.getTemplate()
163
+ let carPlayTemplate = try template.getTemplate()
164
164
 
165
165
  if carPlayTemplate is CPMapTemplate {
166
166
  try await MainActor.run {
@@ -192,23 +192,35 @@ class HybridAutoPlay: HybridAutoPlaySpec {
192
192
 
193
193
  await template.invalidate()
194
194
 
195
- let carPlayTemplate = template.getTemplate()
196
-
197
- if carPlayTemplate is CPAlertTemplate {
198
- let animated = try await !interfaceController.dismissTemplate(
199
- animated: false
200
- )
201
-
202
- let _ = try await interfaceController.presentTemplate(
203
- carPlayTemplate,
204
- animated: animated
195
+ if #available(iOS 27.0, *), let panel = template.getPanel() as? CPMapPanel {
196
+ /// since only the top most panel gets a panelDidHide call when pressing the close button
197
+ /// we hide it globaly to avoid orphaned panels on the navigation stack
198
+ panel.showsCloseButton = false
199
+
200
+ try await interfaceController.pushPanel(
201
+ panel,
202
+ templateId: templateId
205
203
  )
206
204
  }
207
205
  else {
208
- let _ = try await interfaceController.pushTemplate(
209
- carPlayTemplate,
210
- animated: true
211
- )
206
+ let carPlayTemplate = try template.getTemplate()
207
+
208
+ if carPlayTemplate is CPAlertTemplate {
209
+ let animated = try await !interfaceController.dismissTemplate(
210
+ animated: false
211
+ )
212
+
213
+ let _ = try await interfaceController.presentTemplate(
214
+ carPlayTemplate,
215
+ animated: animated
216
+ )
217
+ }
218
+ else {
219
+ let _ = try await interfaceController.pushTemplate(
220
+ carPlayTemplate,
221
+ animated: true
222
+ )
223
+ }
212
224
  }
213
225
 
214
226
  if let autoDismissMs = template.autoDismissMs {
@@ -217,7 +229,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
217
229
  nanoseconds: UInt64(autoDismissMs) * 1_000_000
218
230
  )
219
231
 
220
- if interfaceController.topTemplateId == templateId
232
+ if interfaceController.isTopEntry(templateId: templateId)
221
233
  || interfaceController.interfaceController
222
234
  .presentedTemplate?.id == templateId
223
235
  {
@@ -147,11 +147,15 @@ class HybridMapTemplate: HybridMapTemplateSpec {
147
147
  }
148
148
  }
149
149
 
150
- func startNavigation(templateId: String, trip: TripConfig) throws {
151
- try RootModule.withAutoPlayTemplate(templateId: templateId) {
152
- (template: MapTemplate) in
153
- let trip = Parser.parseTrip(tripConfig: trip)
154
- template.startNavigation(trip: trip)
150
+ func startNavigation(templateId: String, trip: TripConfig) throws -> Promise<Void> {
151
+ return Promise.async {
152
+ try await MainActor.run {
153
+ try RootModule.withAutoPlayTemplate(templateId: templateId) {
154
+ (template: MapTemplate) in
155
+ let trip = Parser.parseTrip(tripConfig: trip)
156
+ template.startNavigation(trip: trip)
157
+ }
158
+ }
155
159
  }
156
160
  }
157
161
 
@@ -168,4 +172,17 @@ class HybridMapTemplate: HybridMapTemplateSpec {
168
172
  template.setManeuverState(state: state)
169
173
  }
170
174
  }
175
+
176
+ func updateOptionsPanel(templateId: String, config: NitroOptionsPanelConfig?) throws
177
+ -> Promise<Void>
178
+ {
179
+ return Promise.async {
180
+ try await MainActor.run {
181
+ try RootModule.withAutoPlayTemplate(templateId: templateId) {
182
+ (template: MapTemplate) in
183
+ template.updateOptionsPanel(config: config)
184
+ }
185
+ }
186
+ }
187
+ }
171
188
  }
@@ -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
  }