@mmerterden/multi-agent-pipeline 20.0.0 → 20.2.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.
- package/CHANGELOG.md +55 -0
- package/README.md +5 -5
- package/README.tr.md +5 -5
- package/SECURITY.md +3 -3
- package/docs/adr/0011-dormant-ci.md +10 -1
- package/docs/architecture.md +2 -2
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +8 -7
- package/install/_codex-agents.mjs +1 -1
- package/manifest.json +92 -64
- package/package.json +1 -1
- package/pipeline/agents/code-reviewer.md +2 -2
- package/pipeline/agents/dev-critic.md +5 -5
- package/pipeline/agents/security-auditor.md +80 -72
- package/pipeline/commands/figma-to-swiftui.md +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/scan/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/security-review/SKILL.md +52 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/multi-agent-refs/component-dispatch.md +5 -5
- package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -6
- package/pipeline/multi-agent-refs/features/security-audit.md +55 -0
- package/pipeline/multi-agent-refs/phases/modes.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +9 -15
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +1 -1
- package/pipeline/multi-agent-refs/threat-model.md +39 -0
- package/pipeline/schemas/agent-state.schema.json +23 -0
- package/pipeline/schemas/phases.json +1 -2
- package/pipeline/schemas/prefs.schema.json +0 -4
- package/pipeline/schemas/reviewer-output.schema.json +99 -2
- package/pipeline/schemas/security-finding.schema.json +144 -0
- package/pipeline/scripts/_stack-routing.mjs +1 -0
- package/pipeline/scripts/gc-abandoned.sh +16 -9
- package/pipeline/scripts/render-work-summary.sh +7 -4
- package/pipeline/skills/.skill-manifest.json +47 -23
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -4
- package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +29 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/security-review/SKILL.md +64 -0
- package/pipeline/skills/shared/external/security-review/references/owasp-mobile-top10-2024.md +53 -0
- package/pipeline/skills/shared/external/security-review/references/owasp-web-api-top10-2021.md +56 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
- package/pipeline/commands/security-review.md +0 -6
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Jetpack Compose Components Patterns
|
|
2
|
+
|
|
3
|
+
Full code examples backing the guidance in `SKILL.md`. Headings mirror the
|
|
4
|
+
SKILL.md sections.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Two-Layer Design Tokens](#two-layer-design-tokens)
|
|
9
|
+
- [Bridging Tokens to Material 3](#bridging-tokens-to-material-3)
|
|
10
|
+
- [Component Catalog](#component-catalog)
|
|
11
|
+
|
|
12
|
+
## Two-Layer Design Tokens
|
|
13
|
+
|
|
14
|
+
Layer 1 is a machine-generated raw-token source: one file, never hand-edited,
|
|
15
|
+
the single source of truth for every primitive value. Layer 2 is a hand-authored
|
|
16
|
+
semantic wrapper: `@Immutable` data classes that name each role and reference the
|
|
17
|
+
raw tokens, never a literal hex or `sp`. Each semantic model exposes a `light()`
|
|
18
|
+
and a `dark()` variant.
|
|
19
|
+
|
|
20
|
+
```kotlin
|
|
21
|
+
// Layer 1: generated, do not edit. Produced by the token codegen pipeline.
|
|
22
|
+
object RawTokens {
|
|
23
|
+
val neutral0 = Color(0xFFFFFFFF)
|
|
24
|
+
val neutral900 = Color(0xFF1C1B1F)
|
|
25
|
+
val neutral1000 = Color(0xFF000000)
|
|
26
|
+
val red600 = Color(0xFFBA1A1A)
|
|
27
|
+
val red200 = Color(0xFFFFB4AB)
|
|
28
|
+
val fontSizeBody = 16.sp
|
|
29
|
+
val lineHeightBody = 24.sp
|
|
30
|
+
val fontSizeTitle = 22.sp
|
|
31
|
+
val lineHeightTitle = 28.sp
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```kotlin
|
|
36
|
+
// Layer 2: hand-authored semantic wrapper.
|
|
37
|
+
@Immutable
|
|
38
|
+
data class AppColors(
|
|
39
|
+
val background: Color,
|
|
40
|
+
val onBackground: Color,
|
|
41
|
+
val brandPrimary: Color,
|
|
42
|
+
val brandOnPrimary: Color,
|
|
43
|
+
val surface: Color,
|
|
44
|
+
val danger: Color,
|
|
45
|
+
) {
|
|
46
|
+
companion object {
|
|
47
|
+
fun light() = AppColors(
|
|
48
|
+
background = RawTokens.neutral0,
|
|
49
|
+
onBackground = RawTokens.neutral900,
|
|
50
|
+
brandPrimary = RawTokens.red600,
|
|
51
|
+
brandOnPrimary = RawTokens.neutral0,
|
|
52
|
+
surface = RawTokens.neutral0,
|
|
53
|
+
danger = RawTokens.red600,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
fun dark() = AppColors(
|
|
57
|
+
background = RawTokens.neutral1000,
|
|
58
|
+
onBackground = RawTokens.neutral0,
|
|
59
|
+
brandPrimary = RawTokens.red200,
|
|
60
|
+
brandOnPrimary = RawTokens.neutral900,
|
|
61
|
+
surface = RawTokens.neutral1000,
|
|
62
|
+
danger = RawTokens.red200,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Immutable
|
|
68
|
+
data class AppTypography(
|
|
69
|
+
val body: TextStyle,
|
|
70
|
+
val title: TextStyle,
|
|
71
|
+
) {
|
|
72
|
+
companion object {
|
|
73
|
+
fun default() = AppTypography(
|
|
74
|
+
body = TextStyle(
|
|
75
|
+
fontSize = RawTokens.fontSizeBody,
|
|
76
|
+
lineHeight = RawTokens.lineHeightBody,
|
|
77
|
+
),
|
|
78
|
+
title = TextStyle(
|
|
79
|
+
fontSize = RawTokens.fontSizeTitle,
|
|
80
|
+
lineHeight = RawTokens.lineHeightTitle,
|
|
81
|
+
fontWeight = FontWeight.Medium,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The codegen pipeline that produces Layer 1 is a separate concern; see the
|
|
89
|
+
`android-design-tokens-codegen` skill. This skill covers the consumption side
|
|
90
|
+
only.
|
|
91
|
+
|
|
92
|
+
## Bridging Tokens to Material 3
|
|
93
|
+
|
|
94
|
+
Bridge the semantic tokens to both worlds. Select an M3
|
|
95
|
+
`light`/`darkColorScheme` built from the tokens so stock Material components
|
|
96
|
+
(`Button`, `TopAppBar`, `Card`) inherit the brand colors, and expose the richer
|
|
97
|
+
token set through `CompositionLocalProvider`. Read the rich set through a
|
|
98
|
+
`Theme.colors` / `Theme.typography` accessor marked `@ReadOnlyComposable`.
|
|
99
|
+
|
|
100
|
+
```kotlin
|
|
101
|
+
val LocalAppColors = staticCompositionLocalOf { AppColors.light() }
|
|
102
|
+
val LocalAppTypography = staticCompositionLocalOf { AppTypography.default() }
|
|
103
|
+
|
|
104
|
+
private fun AppColors.toMaterialColorScheme(darkTheme: Boolean): ColorScheme {
|
|
105
|
+
val base = if (darkTheme) darkColorScheme() else lightColorScheme()
|
|
106
|
+
return base.copy(
|
|
107
|
+
primary = brandPrimary,
|
|
108
|
+
onPrimary = brandOnPrimary,
|
|
109
|
+
background = background,
|
|
110
|
+
onBackground = onBackground,
|
|
111
|
+
surface = surface,
|
|
112
|
+
error = danger,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun AppTypography.toMaterialTypography(): Typography =
|
|
117
|
+
Typography(bodyLarge = body, titleLarge = title)
|
|
118
|
+
|
|
119
|
+
@Composable
|
|
120
|
+
fun AppTheme(
|
|
121
|
+
darkTheme: Boolean = isSystemInDarkTheme(),
|
|
122
|
+
content: @Composable () -> Unit,
|
|
123
|
+
) {
|
|
124
|
+
val colors = if (darkTheme) AppColors.dark() else AppColors.light()
|
|
125
|
+
val typography = AppTypography.default()
|
|
126
|
+
|
|
127
|
+
CompositionLocalProvider(
|
|
128
|
+
LocalAppColors provides colors,
|
|
129
|
+
LocalAppTypography provides typography,
|
|
130
|
+
) {
|
|
131
|
+
MaterialTheme(
|
|
132
|
+
colorScheme = colors.toMaterialColorScheme(darkTheme),
|
|
133
|
+
typography = typography.toMaterialTypography(),
|
|
134
|
+
content = content,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
object Theme {
|
|
140
|
+
val colors: AppColors
|
|
141
|
+
@Composable @ReadOnlyComposable get() = LocalAppColors.current
|
|
142
|
+
val typography: AppTypography
|
|
143
|
+
@Composable @ReadOnlyComposable get() = LocalAppTypography.current
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Stock Material components read `MaterialTheme.colorScheme`; custom components read
|
|
148
|
+
the richer set:
|
|
149
|
+
|
|
150
|
+
```kotlin
|
|
151
|
+
@Composable
|
|
152
|
+
fun PriceTag(text: String) {
|
|
153
|
+
Text(
|
|
154
|
+
text = text,
|
|
155
|
+
color = Theme.colors.brandPrimary,
|
|
156
|
+
style = Theme.typography.title,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Component Catalog
|
|
162
|
+
|
|
163
|
+
A component-catalog module (for example Showkase) renders every annotated
|
|
164
|
+
composable and color/typography token in a browsable in-app gallery. Keep the
|
|
165
|
+
annotation cheap and universal so it can sit on previews across every module, but
|
|
166
|
+
gate the KSP processor behind a build flag so the browser codegen costs nothing
|
|
167
|
+
on a normal build.
|
|
168
|
+
|
|
169
|
+
```kotlin
|
|
170
|
+
// A preview, annotated for the catalog. The annotation dependency is universal.
|
|
171
|
+
@ShowkaseComposable(name = "InfoCard", group = "Cards")
|
|
172
|
+
@Preview
|
|
173
|
+
@Composable
|
|
174
|
+
private fun InfoCardCatalog() {
|
|
175
|
+
AppTheme { InfoCard(/* sample slots */) }
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```kotlin
|
|
180
|
+
// ui module build.gradle.kts
|
|
181
|
+
dependencies {
|
|
182
|
+
implementation(libs.showkase.annotation) // cheap, always present
|
|
183
|
+
if (providers.gradleProperty("enableShowkase").isPresent) {
|
|
184
|
+
ksp(libs.showkase.processor) // browser codegen only when asked
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```kotlin
|
|
190
|
+
// The catalog entry point, compiled only when the processor ran.
|
|
191
|
+
class CatalogActivity : ComponentActivity() {
|
|
192
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
193
|
+
super.onCreate(savedInstanceState)
|
|
194
|
+
setContent { ShowkaseBrowser(getMetadata()) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Build the catalog on demand with `./gradlew :app:installDebug -PenableShowkase`;
|
|
200
|
+
routine builds skip the processor entirely.
|
|
@@ -16,6 +16,9 @@ passing.
|
|
|
16
16
|
- [NavHost Setup](#navhost-setup)
|
|
17
17
|
- [Navigating Between Screens](#navigating-between-screens)
|
|
18
18
|
- [Arguments (Type-Safe)](#arguments-type-safe)
|
|
19
|
+
- [Custom NavType for Complex Arguments](#custom-navtype-for-complex-arguments)
|
|
20
|
+
- [SafeNavController via CompositionLocal](#safenavcontroller-via-compositionlocal)
|
|
21
|
+
- [Navigation as a One-Shot Effect](#navigation-as-a-one-shot-effect)
|
|
19
22
|
- [Nested Navigation Graphs](#nested-navigation-graphs)
|
|
20
23
|
- [Bottom Navigation](#bottom-navigation)
|
|
21
24
|
- [Deep Links](#deep-links)
|
|
@@ -163,10 +166,70 @@ composable<FlightDetailRoute> { backStackEntry ->
|
|
|
163
166
|
| `Int`, `Long`, `Float`, `Boolean` | Native | Serialized directly |
|
|
164
167
|
| `Enum` | Via `@Serializable` | Add `@Serializable` to enum class |
|
|
165
168
|
| `List<String>` | Via serialization | Works with kotlinx.serialization |
|
|
166
|
-
| Custom objects |
|
|
169
|
+
| Custom objects | Via custom `NavType` | Register in a `typeMap` (see below) |
|
|
167
170
|
|
|
168
|
-
Rule:
|
|
169
|
-
|
|
171
|
+
Rule: Prefer primitive IDs and fetch the full object in the destination
|
|
172
|
+
ViewModel; when a small value object must ride in the route, use a custom
|
|
173
|
+
`NavType` (below) rather than flattening it into primitives.
|
|
174
|
+
|
|
175
|
+
## Custom NavType for Complex Arguments
|
|
176
|
+
|
|
177
|
+
Passing a complex `@Serializable` type through a route needs a custom `NavType`
|
|
178
|
+
in a `typeMap` shared by `composable<>()` and every `toRoute` read (else only primitives resolve).
|
|
179
|
+
|
|
180
|
+
```kotlin
|
|
181
|
+
val searchTypeMap = mapOf(typeOf<PassengerFilter>() to PassengerFilterNavType)
|
|
182
|
+
|
|
183
|
+
composable<SearchRoute>(typeMap = searchTypeMap) { entry ->
|
|
184
|
+
val route = entry.toRoute<SearchRoute>(typeMap = searchTypeMap)
|
|
185
|
+
SearchScreen(filter = route.filter)
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A shared holder owns the one `typeMap`; the ViewModel reads the arg back with
|
|
190
|
+
`savedStateHandle.toRoute<SearchRoute>(typeMap = searchTypeMap)`. Full `NavType`
|
|
191
|
+
+ holder: [references/patterns.md#custom-navtype-type-map](references/patterns.md#custom-navtype-type-map).
|
|
192
|
+
|
|
193
|
+
## SafeNavController via CompositionLocal
|
|
194
|
+
|
|
195
|
+
Never give feature code a raw `NavHostController`. Wrap it to expose only the
|
|
196
|
+
methods features need, each in `runCatching` to swallow rapid-tap double-navigation / illegal-state crashes, and provide it via a `CompositionLocal`.
|
|
197
|
+
|
|
198
|
+
```kotlin
|
|
199
|
+
class SafeNavController(private val controller: NavHostController) {
|
|
200
|
+
fun navigate(route: Any) = runCatching { controller.navigate(route) }
|
|
201
|
+
fun navigateReplacing(route: Any, popUpToType: KClass<*>) = runCatching {
|
|
202
|
+
controller.navigate(route) { popUpTo(popUpToType) { inclusive = true }; launchSingleTop = true }
|
|
203
|
+
}
|
|
204
|
+
fun back() = runCatching { controller.popBackStack() }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
val LocalSafeNavController = staticCompositionLocalOf<SafeNavController> { error("not provided") }
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Provide once at the NavHost; screens read `LocalSafeNavController.current`. Full
|
|
211
|
+
setup + helpers: [references/patterns.md#safenavcontroller](references/patterns.md#safenavcontroller).
|
|
212
|
+
|
|
213
|
+
## Navigation as a One-Shot Effect
|
|
214
|
+
|
|
215
|
+
Keep navigation types out of the ViewModel: it emits `NavigateX` events (a
|
|
216
|
+
`sealed interface`) on a one-shot `Channel`; the owning composable translates
|
|
217
|
+
each into a call, keeping the ViewModel navigation-free and unit-testable.
|
|
218
|
+
|
|
219
|
+
```kotlin
|
|
220
|
+
LaunchedEffect(Unit) { // in the composable that owns the NavController
|
|
221
|
+
viewModel.navEvents.collect { event ->
|
|
222
|
+
when (event) {
|
|
223
|
+
is HomeNavEvent.ToDetail -> nav.navigate(DetailRoute(event.id))
|
|
224
|
+
HomeNavEvent.ToSearch -> nav.navigate(SearchRoute())
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Full `sealed interface` + ViewModel channel wiring:
|
|
231
|
+
[references/patterns.md#navigation-as-a-one-shot-effect](references/patterns.md#navigation-as-a-one-shot-effect).
|
|
232
|
+
Complements the event seam in the `android-mvi-viewmodel` skill.
|
|
170
233
|
|
|
171
234
|
## Nested Navigation Graphs
|
|
172
235
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Compose Navigation -- Reference Patterns
|
|
2
|
+
|
|
3
|
+
Full code for the patterns summarized in `SKILL.md`. Load the section you need
|
|
4
|
+
when implementing that specific navigation feature.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Custom NavType Type-Map](#custom-navtype-type-map)
|
|
9
|
+
- [SafeNavController](#safenavcontroller)
|
|
10
|
+
- [Navigation as a One-Shot Effect](#navigation-as-a-one-shot-effect)
|
|
11
|
+
|
|
12
|
+
## Custom NavType Type-Map
|
|
13
|
+
|
|
14
|
+
Type-safe routes serialize primitives out of the box. A complex `@Serializable`
|
|
15
|
+
argument needs a custom `NavType` plus a `typeMap` shared by the
|
|
16
|
+
`composable<>()` registration and every `toRoute` read. Without the type-map,
|
|
17
|
+
only primitive nav args resolve and the route fails to build.
|
|
18
|
+
|
|
19
|
+
```kotlin
|
|
20
|
+
@Serializable
|
|
21
|
+
data class PassengerFilter(val cabin: String, val adults: Int, val children: Int)
|
|
22
|
+
|
|
23
|
+
@Serializable
|
|
24
|
+
data class SearchRoute(val filter: PassengerFilter)
|
|
25
|
+
|
|
26
|
+
val PassengerFilterNavType = object : NavType<PassengerFilter>(isNullableAllowed = false) {
|
|
27
|
+
override fun get(bundle: Bundle, key: String): PassengerFilter? =
|
|
28
|
+
bundle.getString(key)?.let { Json.decodeFromString(it) }
|
|
29
|
+
|
|
30
|
+
override fun parseValue(value: String): PassengerFilter =
|
|
31
|
+
Json.decodeFromString(Uri.decode(value))
|
|
32
|
+
|
|
33
|
+
override fun serializeAsValue(value: PassengerFilter): String =
|
|
34
|
+
Uri.encode(Json.encodeToString(value))
|
|
35
|
+
|
|
36
|
+
override fun put(bundle: Bundle, key: String, value: PassengerFilter) {
|
|
37
|
+
bundle.putString(key, Json.encodeToString(value))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Shared holder
|
|
43
|
+
|
|
44
|
+
One holder owns the map so the registration and every read agree:
|
|
45
|
+
|
|
46
|
+
```kotlin
|
|
47
|
+
object NavTypeMaps {
|
|
48
|
+
val search: Map<KType, NavType<*>> =
|
|
49
|
+
mapOf(typeOf<PassengerFilter>() to PassengerFilterNavType)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Registration and read
|
|
54
|
+
|
|
55
|
+
```kotlin
|
|
56
|
+
composable<SearchRoute>(typeMap = NavTypeMaps.search) { entry ->
|
|
57
|
+
val route = entry.toRoute<SearchRoute>(typeMap = NavTypeMaps.search)
|
|
58
|
+
SearchScreen(filter = route.filter)
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Reading in the ViewModel
|
|
63
|
+
|
|
64
|
+
`SavedStateHandle.toRoute` takes the same map:
|
|
65
|
+
|
|
66
|
+
```kotlin
|
|
67
|
+
@HiltViewModel
|
|
68
|
+
class SearchViewModel @Inject constructor(
|
|
69
|
+
savedStateHandle: SavedStateHandle,
|
|
70
|
+
) : ViewModel() {
|
|
71
|
+
private val filter: PassengerFilter =
|
|
72
|
+
savedStateHandle.toRoute<SearchRoute>(typeMap = NavTypeMaps.search).filter
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## SafeNavController
|
|
77
|
+
|
|
78
|
+
Feature code should never hold a raw `NavHostController`. Wrap it so features see
|
|
79
|
+
only the methods they need, and wrap each call in `runCatching` so a
|
|
80
|
+
double-navigation or illegal-state exception from a rapid double tap is
|
|
81
|
+
swallowed instead of crashing.
|
|
82
|
+
|
|
83
|
+
```kotlin
|
|
84
|
+
class SafeNavController(private val controller: NavHostController) {
|
|
85
|
+
|
|
86
|
+
fun navigate(route: Any) {
|
|
87
|
+
runCatching { controller.navigate(route) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fun navigateReplacing(route: Any, popUpToType: KClass<*>) {
|
|
91
|
+
runCatching {
|
|
92
|
+
controller.navigate(route) {
|
|
93
|
+
popUpTo(popUpToType) { inclusive = true }
|
|
94
|
+
launchSingleTop = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun navigateClearingStack(route: Any) {
|
|
100
|
+
runCatching {
|
|
101
|
+
controller.navigate(route) { popUpTo(0) { inclusive = true } }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fun back() {
|
|
106
|
+
runCatching { controller.popBackStack() }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
val LocalSafeNavController = staticCompositionLocalOf<SafeNavController> {
|
|
111
|
+
error("SafeNavController not provided")
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Providing it once at the NavHost
|
|
116
|
+
|
|
117
|
+
```kotlin
|
|
118
|
+
@Composable
|
|
119
|
+
fun AppNavHost(navController: NavHostController = rememberNavController()) {
|
|
120
|
+
val safeNav = remember(navController) { SafeNavController(navController) }
|
|
121
|
+
CompositionLocalProvider(LocalSafeNavController provides safeNav) {
|
|
122
|
+
NavHost(navController = navController, startDestination = HomeRoute) {
|
|
123
|
+
composable<HomeRoute> { HomeRoute() }
|
|
124
|
+
composable<DetailRoute> { DetailRoute() }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Feature screens read `LocalSafeNavController.current` and never receive the raw
|
|
131
|
+
controller as a parameter.
|
|
132
|
+
|
|
133
|
+
## Navigation as a One-Shot Effect
|
|
134
|
+
|
|
135
|
+
The ViewModel emits navigation intents as one-shot events; the composable that
|
|
136
|
+
owns the `NavController` translates each into a navigation call. This keeps the
|
|
137
|
+
ViewModel free of navigation types and unit-testable (assert emitted events, no
|
|
138
|
+
`NavController` mock needed).
|
|
139
|
+
|
|
140
|
+
### ViewModel side
|
|
141
|
+
|
|
142
|
+
```kotlin
|
|
143
|
+
sealed interface HomeNavEvent {
|
|
144
|
+
data class ToDetail(val id: String) : HomeNavEvent
|
|
145
|
+
data object ToSearch : HomeNavEvent
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@HiltViewModel
|
|
149
|
+
class HomeViewModel @Inject constructor() : ViewModel() {
|
|
150
|
+
|
|
151
|
+
private val _navEvents = Channel<HomeNavEvent>(Channel.BUFFERED)
|
|
152
|
+
val navEvents: Flow<HomeNavEvent> = _navEvents.receiveAsFlow()
|
|
153
|
+
|
|
154
|
+
fun onItemClick(id: String) {
|
|
155
|
+
viewModelScope.launch { _navEvents.send(HomeNavEvent.ToDetail(id)) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fun onSearchClick() {
|
|
159
|
+
viewModelScope.launch { _navEvents.send(HomeNavEvent.ToSearch) }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Composable side
|
|
165
|
+
|
|
166
|
+
```kotlin
|
|
167
|
+
@Composable
|
|
168
|
+
fun HomeRoute(viewModel: HomeViewModel = hiltViewModel()) {
|
|
169
|
+
val nav = LocalSafeNavController.current
|
|
170
|
+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
171
|
+
|
|
172
|
+
LaunchedEffect(Unit) {
|
|
173
|
+
viewModel.navEvents.collect { event ->
|
|
174
|
+
when (event) {
|
|
175
|
+
is HomeNavEvent.ToDetail -> nav.navigate(DetailRoute(event.id))
|
|
176
|
+
HomeNavEvent.ToSearch -> nav.navigate(SearchRoute(PassengerFilter("ECONOMY", 1, 0)))
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
HomeScreen(
|
|
182
|
+
state = uiState,
|
|
183
|
+
onItemClick = viewModel::onItemClick,
|
|
184
|
+
onSearchClick = viewModel::onSearchClick,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The `Channel` (not a `StateFlow`) guarantees each navigation fires once and is
|
|
190
|
+
not replayed on recomposition or config change. This complements the event seam
|
|
191
|
+
in the `android-mvi-viewmodel` skill.
|