react-native-audio-browser 0.2.0-next.21 → 0.2.0-next.23
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.
- package/README.md +1 -1
- package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +28 -7
- package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +2 -0
- package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +32 -8
- package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +21 -0
- package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +6 -0
- package/ios/Browser/InertStyleDiagnostic.swift +25 -8
- package/ios/Browser/SectionPresentation.swift +18 -5
- package/ios/CarPlay/CPInterfaceController+Safe.swift +93 -23
- package/ios/CarPlay/CarPlayListItemFactory.swift +74 -54
- package/ios/CarPlay/TopTemplateReplacer.swift +96 -0
- package/ios/Model/NitroTypeStubs.swift +3 -0
- package/ios/Tests/InertStyleDiagnosticTests.swift +17 -8
- package/ios/Tests/JsonModelsTests.swift +14 -0
- package/ios/Tests/SectionPresentationTests.swift +21 -13
- package/ios/Tests/StyleResolverTests.swift +5 -0
- package/ios/Tests/TopTemplateReplacerTests.swift +172 -0
- package/lib/typescript/src/types/browser-nodes.d.ts +28 -21
- package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JGridTile.hpp +3 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +3 -2
- package/nitrogen/generated/ios/swift/GridTile.swift +4 -0
- package/nitrogen/generated/shared/c++/GridTile.hpp +6 -2
- package/package.json +1 -1
- package/src/types/browser-nodes.ts +28 -21
- package/website/guide/artwork.md +1 -1
- package/website/guide/browser.md +11 -5
- package/website/guide/carplay.md +30 -0
- package/website/guide/track.md +1 -1
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ Full guides and the complete [API Reference](https://audiobrowser.dev/api/) live
|
|
|
172
172
|
|
|
173
173
|
**Troubleshooting** — [Networking in native callbacks](https://audiobrowser.dev/guide/native-callback-fetch) · [Android SSL / trust anchors](https://audiobrowser.dev/guide/android-certificates)
|
|
174
174
|
|
|
175
|
-
The example app in [`apps/example-native`](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native) browses archive.org's audio collection — in-app, on CarPlay, and on Android Auto — with search, favorites, and the gate.
|
|
175
|
+
The example app in [`apps/example-native`](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native) browses archive.org's audio collection — in-app, on CarPlay, and on Android Auto — with search, favorites, and the gate. Its **Style Lab** tab renders every CarPlay `style` declaration, each shelf titled with the block it draws ([source](https://github.com/radio-garden/react-native-audio-browser/blob/main/apps/example-native/src/utils/style-lab.ts)).
|
|
176
176
|
|
|
177
177
|
### For coding agents
|
|
178
178
|
|
|
@@ -66,8 +66,8 @@ object InertStyleDiagnostic {
|
|
|
66
66
|
when (this) {
|
|
67
67
|
GRID_WRAP,
|
|
68
68
|
GRID_TILE -> !context.isGrid
|
|
69
|
-
IMAGE_SHAPE -> !context.
|
|
70
|
-
ACCESSORY_SYMBOL -> context.
|
|
69
|
+
IMAGE_SHAPE -> !context.isShaped
|
|
70
|
+
ACCESSORY_SYMBOL -> context.isGrid && !context.isShaped
|
|
71
71
|
CARD_TINT,
|
|
72
72
|
CARD_IMAGE -> !context.isCard
|
|
73
73
|
}
|
|
@@ -78,8 +78,9 @@ object InertStyleDiagnostic {
|
|
|
78
78
|
when (this) {
|
|
79
79
|
GRID_WRAP,
|
|
80
80
|
GRID_TILE -> "`$key` renders only in a grid"
|
|
81
|
-
IMAGE_SHAPE -> "only
|
|
82
|
-
ACCESSORY_SYMBOL ->
|
|
81
|
+
IMAGE_SHAPE -> "only image and condensed tiles have a shape"
|
|
82
|
+
ACCESSORY_SYMBOL ->
|
|
83
|
+
"among grid tiles only image and condensed ones have an accessory slot"
|
|
83
84
|
CARD_TINT,
|
|
84
85
|
CARD_IMAGE -> "the card properties render only under `gridTile: 'card'`"
|
|
85
86
|
}
|
|
@@ -89,8 +90,9 @@ object InertStyleDiagnostic {
|
|
|
89
90
|
when (this) {
|
|
90
91
|
GRID_WRAP,
|
|
91
92
|
GRID_TILE -> "Declare `display: 'grid'` on the section or the page, or drop `$key`."
|
|
92
|
-
IMAGE_SHAPE -> "
|
|
93
|
-
ACCESSORY_SYMBOL ->
|
|
93
|
+
IMAGE_SHAPE -> "Use `gridTile: 'image'` or `'condensed'` in a grid, or drop `imageShape`."
|
|
94
|
+
ACCESSORY_SYMBOL ->
|
|
95
|
+
"Use `gridTile: 'image'` or `'condensed'`, or a list, or drop `accessorySymbol`."
|
|
94
96
|
CARD_TINT,
|
|
95
97
|
CARD_IMAGE -> "Declare `display: 'grid'` with `gridTile: 'card'`, or drop `$key`."
|
|
96
98
|
}
|
|
@@ -146,9 +148,28 @@ object InertStyleDiagnostic {
|
|
|
146
148
|
|
|
147
149
|
/**
|
|
148
150
|
* Whether the card treatment is what this container renders — the `card*` family's condition.
|
|
151
|
+
* Exhaustive, like [isShaped]: a new tile kind has to be ruled on here.
|
|
149
152
|
*/
|
|
150
153
|
val isCard: Boolean
|
|
151
|
-
get() =
|
|
154
|
+
get() =
|
|
155
|
+
isGrid &&
|
|
156
|
+
when (tile) {
|
|
157
|
+
GridTile.CARD -> true
|
|
158
|
+
GridTile.PLAIN,
|
|
159
|
+
GridTile.IMAGE,
|
|
160
|
+
GridTile.CONDENSED -> false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Whether this container renders a tile family with a shape and an accessory slot. */
|
|
164
|
+
val isShaped: Boolean
|
|
165
|
+
get() =
|
|
166
|
+
isGrid &&
|
|
167
|
+
when (tile) {
|
|
168
|
+
GridTile.IMAGE,
|
|
169
|
+
GridTile.CONDENSED -> true
|
|
170
|
+
GridTile.PLAIN,
|
|
171
|
+
GridTile.CARD -> false
|
|
172
|
+
}
|
|
152
173
|
|
|
153
174
|
/**
|
|
154
175
|
* The effective style, for the message — in the wire spelling the author types ([toWireString]
|
|
@@ -82,6 +82,7 @@ internal fun String?.toImageShape(): ImageShape? =
|
|
|
82
82
|
internal fun GridTile.toWireString(): String =
|
|
83
83
|
when (this) {
|
|
84
84
|
GridTile.PLAIN -> "plain"
|
|
85
|
+
GridTile.IMAGE -> "image"
|
|
85
86
|
GridTile.CARD -> "card"
|
|
86
87
|
GridTile.CONDENSED -> "condensed"
|
|
87
88
|
}
|
|
@@ -89,6 +90,7 @@ internal fun GridTile.toWireString(): String =
|
|
|
89
90
|
internal fun String?.toGridTile(): GridTile? =
|
|
90
91
|
when (this?.takeIf { it.isNotEmpty() }?.lowercase()) {
|
|
91
92
|
"plain" -> GridTile.PLAIN
|
|
93
|
+
"image" -> GridTile.IMAGE
|
|
92
94
|
"card" -> GridTile.CARD
|
|
93
95
|
"condensed" -> GridTile.CONDENSED
|
|
94
96
|
else -> null
|
|
@@ -100,6 +100,7 @@ class InertStyleDiagnosticTest {
|
|
|
100
100
|
@Test
|
|
101
101
|
fun `imageShape needs a shaped tile`() {
|
|
102
102
|
val list = sectionStyle(imageShape = ImageShape.CIRCULAR)
|
|
103
|
+
val plain = sectionStyle(display = StyleDisplay.GRID, imageShape = ImageShape.CIRCULAR)
|
|
103
104
|
val cards =
|
|
104
105
|
sectionStyle(
|
|
105
106
|
display = StyleDisplay.GRID,
|
|
@@ -112,26 +113,49 @@ class InertStyleDiagnosticTest {
|
|
|
112
113
|
gridTile = GridTile.CONDENSED,
|
|
113
114
|
imageShape = ImageShape.CIRCULAR,
|
|
114
115
|
)
|
|
116
|
+
val image =
|
|
117
|
+
sectionStyle(
|
|
118
|
+
display = StyleDisplay.GRID,
|
|
119
|
+
gridTile = GridTile.IMAGE,
|
|
120
|
+
imageShape = ImageShape.CIRCULAR,
|
|
121
|
+
)
|
|
115
122
|
assertEquals(1, findings(listOf(section(style = list))).size)
|
|
123
|
+
assertEquals(1, findings(listOf(section(style = plain))).size)
|
|
116
124
|
assertEquals(1, findings(listOf(section(style = cards))).size)
|
|
117
|
-
// Plain and condensed tiles both take a shape.
|
|
118
125
|
assertTrue(findings(listOf(section(style = condensed))).isEmpty())
|
|
126
|
+
assertTrue(findings(listOf(section(style = image))).isEmpty())
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
@Test
|
|
122
|
-
fun `accessorySymbol
|
|
130
|
+
fun `accessorySymbol needs an accessory slot`() {
|
|
123
131
|
val rows = sectionStyle(accessorySymbol = "lock.fill")
|
|
132
|
+
val condensed =
|
|
133
|
+
sectionStyle(
|
|
134
|
+
display = StyleDisplay.GRID,
|
|
135
|
+
gridTile = GridTile.CONDENSED,
|
|
136
|
+
accessorySymbol = "lock.fill",
|
|
137
|
+
)
|
|
138
|
+
val image =
|
|
139
|
+
sectionStyle(
|
|
140
|
+
display = StyleDisplay.GRID,
|
|
141
|
+
gridTile = GridTile.IMAGE,
|
|
142
|
+
accessorySymbol = "lock.fill",
|
|
143
|
+
)
|
|
144
|
+
val plain = sectionStyle(display = StyleDisplay.GRID, accessorySymbol = "lock.fill")
|
|
124
145
|
val cards =
|
|
125
146
|
sectionStyle(
|
|
126
147
|
display = StyleDisplay.GRID,
|
|
127
148
|
gridTile = GridTile.CARD,
|
|
128
149
|
accessorySymbol = "lock.fill",
|
|
129
150
|
)
|
|
130
|
-
// List rows draw accessories; card elements have no slot for one.
|
|
131
151
|
assertTrue(findings(listOf(section(style = rows))).isEmpty())
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
152
|
+
assertTrue(findings(listOf(section(style = condensed))).isEmpty())
|
|
153
|
+
assertTrue(findings(listOf(section(style = image))).isEmpty())
|
|
154
|
+
for (style in listOf(plain, cards)) {
|
|
155
|
+
val found = findings(listOf(section(style = style)))
|
|
156
|
+
assertEquals(1, found.size)
|
|
157
|
+
assertTrue(found[0], found[0].contains("accessory slot"))
|
|
158
|
+
}
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
@Test
|
|
@@ -167,10 +191,10 @@ class InertStyleDiagnosticTest {
|
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
@Test
|
|
170
|
-
fun `a track's own shape is live in a
|
|
194
|
+
fun `a track's own shape is live in a shaped grid`() {
|
|
171
195
|
val grid =
|
|
172
196
|
section(
|
|
173
|
-
style = sectionStyle(display = StyleDisplay.GRID),
|
|
197
|
+
style = sectionStyle(display = StyleDisplay.GRID, gridTile = GridTile.IMAGE),
|
|
174
198
|
children = arrayOf(track(style = trackStyle(imageShape = ImageShape.CIRCULAR))),
|
|
175
199
|
)
|
|
176
200
|
assertTrue(findings(listOf(grid)).isEmpty())
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.audiobrowser.browser
|
|
2
2
|
|
|
3
|
+
import com.audiobrowser.util.toGridTile
|
|
4
|
+
import com.audiobrowser.util.toWireString
|
|
3
5
|
import com.margelo.nitro.audiobrowser.ArtworkRendering
|
|
4
6
|
import com.margelo.nitro.audiobrowser.CardImage
|
|
5
7
|
import com.margelo.nitro.audiobrowser.GridTile
|
|
@@ -134,6 +136,25 @@ class JsonStyleTest {
|
|
|
134
136
|
assertEquals(CardImage.BACKGROUND, style?.cardImage)
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
@Test
|
|
140
|
+
fun `decodes the image family`() {
|
|
141
|
+
val resolved =
|
|
142
|
+
json.decodeFromString<JsonResolvedTrack>(
|
|
143
|
+
"""{"path":"/home","title":"Home","sections":[
|
|
144
|
+
{"style":{"display":"grid","gridTile":"image","imageShape":"circular"},
|
|
145
|
+
"children":[{"title":"C","src":"s"}]}
|
|
146
|
+
]}"""
|
|
147
|
+
)
|
|
148
|
+
val style = resolved.toNitro().sections?.first()?.style
|
|
149
|
+
assertEquals(GridTile.IMAGE, style?.gridTile)
|
|
150
|
+
assertEquals(ImageShape.CIRCULAR, style?.imageShape)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@Test
|
|
154
|
+
fun `every gridTile survives the wire`() {
|
|
155
|
+
GridTile.entries.forEach { assertEquals(it, it.toWireString().toGridTile()) }
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
@Test
|
|
138
159
|
fun `unknown gridTile decodes as no declaration`() {
|
|
139
160
|
val resolved =
|
|
@@ -132,6 +132,12 @@ class StyleResolverTest {
|
|
|
132
132
|
page = sectionStyle(gridTile = GridTile.CARD),
|
|
133
133
|
)
|
|
134
134
|
assertEquals(GridTile.PLAIN, overridden.gridTile)
|
|
135
|
+
val image =
|
|
136
|
+
StyleResolver.sectionStyle(
|
|
137
|
+
section = sectionStyle(gridTile = GridTile.IMAGE),
|
|
138
|
+
page = sectionStyle(gridTile = GridTile.CARD),
|
|
139
|
+
)
|
|
140
|
+
assertEquals(GridTile.IMAGE, image.gridTile)
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
@Test
|
|
@@ -69,8 +69,8 @@ enum InertStyleDiagnostic {
|
|
|
69
69
|
func isInert(in context: Context) -> Bool {
|
|
70
70
|
switch self {
|
|
71
71
|
case .gridWrap, .gridTile: !context.isGrid
|
|
72
|
-
case .imageShape: !context.
|
|
73
|
-
case .accessorySymbol: context.
|
|
72
|
+
case .imageShape: !context.isShaped
|
|
73
|
+
case .accessorySymbol: context.isGrid && !context.isShaped
|
|
74
74
|
case .cardTint, .cardImage: !context.isCard
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -79,8 +79,8 @@ enum InertStyleDiagnostic {
|
|
|
79
79
|
var rule: String {
|
|
80
80
|
switch self {
|
|
81
81
|
case .gridWrap, .gridTile: "`\(rawValue)` renders only in a grid"
|
|
82
|
-
case .imageShape: "only
|
|
83
|
-
case .accessorySymbol: "
|
|
82
|
+
case .imageShape: "only image and condensed tiles have a shape"
|
|
83
|
+
case .accessorySymbol: "among grid tiles only image and condensed ones have an accessory slot"
|
|
84
84
|
case .cardTint, .cardImage: "the card properties render only under `gridTile: 'card'`"
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -90,9 +90,9 @@ enum InertStyleDiagnostic {
|
|
|
90
90
|
case .gridWrap, .gridTile:
|
|
91
91
|
"Declare `display: 'grid'` on the section or the page, or drop `\(rawValue)`."
|
|
92
92
|
case .imageShape:
|
|
93
|
-
"
|
|
93
|
+
"Use `gridTile: 'image'` or `'condensed'` in a grid, or drop `imageShape`."
|
|
94
94
|
case .accessorySymbol:
|
|
95
|
-
"Use `gridTile: '
|
|
95
|
+
"Use `gridTile: 'image'` or `'condensed'`, or a list, or drop `accessorySymbol`."
|
|
96
96
|
case .cardTint, .cardImage:
|
|
97
97
|
"Declare `display: 'grid'` with `gridTile: 'card'`, or drop `\(rawValue)`."
|
|
98
98
|
}
|
|
@@ -129,8 +129,25 @@ enum InertStyleDiagnostic {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
/// Whether the card treatment is what this container renders — the
|
|
132
|
-
/// condition the `card*` family is named after.
|
|
133
|
-
|
|
132
|
+
/// condition the `card*` family is named after. Exhaustive, like
|
|
133
|
+
/// `isShaped`: a new tile kind has to be ruled on here.
|
|
134
|
+
var isCard: Bool {
|
|
135
|
+
guard isGrid else { return false }
|
|
136
|
+
return switch tile {
|
|
137
|
+
case .card: true
|
|
138
|
+
case .plain, .image, .condensed: false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Whether this container renders a tile family with a shape and an
|
|
143
|
+
/// accessory slot.
|
|
144
|
+
var isShaped: Bool {
|
|
145
|
+
guard isGrid else { return false }
|
|
146
|
+
return switch tile {
|
|
147
|
+
case .image, .condensed: true
|
|
148
|
+
case .plain, .card: false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
134
151
|
|
|
135
152
|
/// The effective style, for the message — in the wire spelling the author
|
|
136
153
|
/// types (`stringValue` is the generated enum's own mapping). `gridTile`
|
|
@@ -41,11 +41,23 @@ enum SectionPresentation: Equatable {
|
|
|
41
41
|
return symbol
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/// The title a tile shelf draws as the list section's header; nil when
|
|
45
|
+
/// the image row's own text carries it — here, not in the CarPlay factory,
|
|
46
|
+
/// so the rule is testable off-device. CarPlay draws a chevron beside an
|
|
47
|
+
/// image row's text whether or not the row has a handler, so only a shelf
|
|
48
|
+
/// with a "view all" `path` keeps its title there.
|
|
49
|
+
///
|
|
50
|
+
/// - Parameter supportsElements: whether the platform has the iOS 26
|
|
51
|
+
/// element API; the legacy image row's text is not optional.
|
|
52
|
+
static func tileSectionHeader(_ section: Section, supportsElements: Bool) -> String? {
|
|
53
|
+
supportsElements && section.path == nil ? section.title : nil
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
/// The element family a tile presentation renders on iOS 26+ — here, not
|
|
45
57
|
/// in the CarPlay factory, so the style→family mapping is testable
|
|
46
|
-
/// off-device. `gridTile` picks the family (plain by default)
|
|
47
|
-
///
|
|
48
|
-
///
|
|
58
|
+
/// off-device. `gridTile` picks the family (plain by default), in either
|
|
59
|
+
/// wrap mode: a plain tile is a row element (title + subtitle), an image
|
|
60
|
+
/// tile an image-grid element (title, shape, accessory). Pre-26 has no element API at all: the treatment drops
|
|
49
61
|
/// with the legacy image row (decorations drop before layout).
|
|
50
62
|
enum TileElementFamily: Equatable {
|
|
51
63
|
case rowElements
|
|
@@ -54,11 +66,12 @@ enum SectionPresentation: Equatable {
|
|
|
54
66
|
case condensedElements
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
static func tileFamily(for style: SectionStyle
|
|
69
|
+
static func tileFamily(for style: SectionStyle?) -> TileElementFamily {
|
|
58
70
|
switch style?.gridTile {
|
|
71
|
+
case .image: .imageGridElements
|
|
59
72
|
case .card: .cardElements
|
|
60
73
|
case .condensed: .condensedElements
|
|
61
|
-
case .plain, nil:
|
|
74
|
+
case .plain, nil: .rowElements
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
}
|
|
@@ -12,6 +12,10 @@ import os.log
|
|
|
12
12
|
/// preconditions are checked up front, the underlying call always gets a
|
|
13
13
|
/// completion, and every failure is logged, with an optional caller completion
|
|
14
14
|
/// for call sites that chain on the result.
|
|
15
|
+
///
|
|
16
|
+
/// A push onto a full stack replaces the top template (`TopTemplateReplacer`).
|
|
17
|
+
/// The stack mutations wait for that replacement to settle before they run,
|
|
18
|
+
/// so none of them lands between its pop and its push.
|
|
15
19
|
@MainActor
|
|
16
20
|
extension CPInterfaceController {
|
|
17
21
|
private static let stackLogger = Logger(subsystem: "com.audiobrowser", category: "TemplateStack")
|
|
@@ -26,9 +30,43 @@ extension CPInterfaceController {
|
|
|
26
30
|
/// push — taking down the whole app, not just the CarPlay scene.
|
|
27
31
|
private static let maximumStackDepth = 5
|
|
28
32
|
|
|
33
|
+
private static var topReplacerKey: UInt8 = 0
|
|
34
|
+
|
|
35
|
+
private var topReplacer: TopTemplateReplacer {
|
|
36
|
+
if let replacer = objc_getAssociatedObject(self, &Self.topReplacerKey) as? TopTemplateReplacer {
|
|
37
|
+
return replacer
|
|
38
|
+
}
|
|
39
|
+
let replacer = TopTemplateReplacer()
|
|
40
|
+
objc_setAssociatedObject(self, &Self.topReplacerKey, replacer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
41
|
+
return replacer
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private func whenStackSettled(_ mutation: @escaping (CPInterfaceController) -> Void) {
|
|
45
|
+
topReplacer.whenSettled { [weak self] in
|
|
46
|
+
guard let self else { return }
|
|
47
|
+
mutation(self)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Pushes `template`, skipping the raise case (already in the hierarchy). At
|
|
52
|
+
/// the depth limit it replaces the top template.
|
|
53
|
+
func safePush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
54
|
+
whenStackSettled { controller in
|
|
55
|
+
let templates = controller.templates
|
|
56
|
+
guard templates.count >= Self.maximumStackDepth, !templates.contains(where: { $0 === template }) else {
|
|
57
|
+
controller.guardedPush(template, animated: animated, completion: completion)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
controller.topReplacer.push(
|
|
61
|
+
template, replacingTopOf: GuardedTemplateStack(controller: controller), animated: animated,
|
|
62
|
+
completion: completion,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
29
67
|
/// Pushes `template`, skipping the raise cases (already in the hierarchy,
|
|
30
68
|
/// stack at the depth limit).
|
|
31
|
-
func
|
|
69
|
+
fileprivate func guardedPush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)?) {
|
|
32
70
|
guard !templates.contains(where: { $0 === template }) else {
|
|
33
71
|
Self.stackLogger.warning("push skipped: \(Self.name(of: template)) is already in the stack")
|
|
34
72
|
completion?(false, nil)
|
|
@@ -48,7 +86,7 @@ extension CPInterfaceController {
|
|
|
48
86
|
}
|
|
49
87
|
|
|
50
88
|
/// Pops the top template, skipping the raise case (nothing above the root).
|
|
51
|
-
func
|
|
89
|
+
fileprivate func guardedPop(animated: Bool, completion: ((Bool, Error?) -> Void)?) {
|
|
52
90
|
guard templates.count > 1 else {
|
|
53
91
|
Self.stackLogger.warning("pop skipped: already at the root template")
|
|
54
92
|
completion?(false, nil)
|
|
@@ -62,41 +100,54 @@ extension CPInterfaceController {
|
|
|
62
100
|
}
|
|
63
101
|
}
|
|
64
102
|
|
|
103
|
+
/// Pops the top template, skipping the raise case (nothing above the root).
|
|
104
|
+
func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
105
|
+
whenStackSettled { controller in
|
|
106
|
+
controller.guardedPop(animated: animated, completion: completion)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
65
110
|
/// Pops back to `template`, skipping the raise case (not in the hierarchy).
|
|
66
111
|
/// A no-op success when it is already the top template.
|
|
67
112
|
func safePop(to template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
113
|
+
whenStackSettled { controller in
|
|
114
|
+
guard controller.templates.contains(where: { $0 === template }) else {
|
|
115
|
+
Self.stackLogger.warning("pop(to:) skipped: \(Self.name(of: template)) is not in the stack")
|
|
116
|
+
completion?(false, nil)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
guard controller.topTemplate !== template else {
|
|
120
|
+
completion?(true, nil)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
controller.pop(to: template, animated: animated) { popped, error in
|
|
124
|
+
if let error {
|
|
125
|
+
Self.stackLogger.error("pop(to:) \(Self.name(of: template)) failed: \(error.localizedDescription)")
|
|
126
|
+
}
|
|
127
|
+
completion?(popped, error)
|
|
80
128
|
}
|
|
81
|
-
completion?(popped, error)
|
|
82
129
|
}
|
|
83
130
|
}
|
|
84
131
|
|
|
85
132
|
func safePopToRoot(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
133
|
+
whenStackSettled { controller in
|
|
134
|
+
controller.popToRootTemplate(animated: animated) { popped, error in
|
|
135
|
+
if let error {
|
|
136
|
+
Self.stackLogger.error("popToRootTemplate failed: \(error.localizedDescription)")
|
|
137
|
+
}
|
|
138
|
+
completion?(popped, error)
|
|
89
139
|
}
|
|
90
|
-
completion?(popped, error)
|
|
91
140
|
}
|
|
92
141
|
}
|
|
93
142
|
|
|
94
143
|
func safeSetRoot(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
144
|
+
whenStackSettled { controller in
|
|
145
|
+
controller.setRootTemplate(template, animated: animated) { set, error in
|
|
146
|
+
if let error {
|
|
147
|
+
Self.stackLogger.error("setRootTemplate \(Self.name(of: template)) failed: \(error.localizedDescription)")
|
|
148
|
+
}
|
|
149
|
+
completion?(set, error)
|
|
98
150
|
}
|
|
99
|
-
completion?(set, error)
|
|
100
151
|
}
|
|
101
152
|
}
|
|
102
153
|
|
|
@@ -124,3 +175,22 @@ extension CPInterfaceController {
|
|
|
124
175
|
}
|
|
125
176
|
}
|
|
126
177
|
}
|
|
178
|
+
|
|
179
|
+
/// The guarded pop and push a replacement is made of, so a push that still
|
|
180
|
+
/// cannot fit skips and logs rather than raising.
|
|
181
|
+
@MainActor
|
|
182
|
+
private struct GuardedTemplateStack: TemplateStack {
|
|
183
|
+
let controller: CPInterfaceController
|
|
184
|
+
|
|
185
|
+
var templates: [CPTemplate] {
|
|
186
|
+
controller.templates
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func popTop(completion: @escaping (Bool, Error?) -> Void) {
|
|
190
|
+
controller.guardedPop(animated: false, completion: completion)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func push(_ template: CPTemplate, animated: Bool, completion: @escaping (Bool, Error?) -> Void) {
|
|
194
|
+
controller.guardedPush(template, animated: animated, completion: completion)
|
|
195
|
+
}
|
|
196
|
+
}
|