@mmerterden/multi-agent-pipeline 20.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/docs/facts.json +5 -5
  3. package/manifest.json +52 -31
  4. package/package.json +1 -1
  5. package/pipeline/skills/.skill-manifest.json +36 -20
  6. package/pipeline/skills/.skills-index.json +75 -9
  7. package/pipeline/skills/shared/README.md +13 -7
  8. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  9. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  10. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  11. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  12. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  13. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  14. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  15. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  16. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  17. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  18. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  19. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  20. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  21. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  22. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  23. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  24. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  25. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  26. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  27. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  28. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  29. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  30. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  31. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  32. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  33. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  34. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  35. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  36. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  37. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  38. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  39. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  40. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  41. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  42. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  43. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  44. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  45. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  46. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  47. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  48. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  49. package/pipeline/skills/skills-index.md +8 -2
@@ -0,0 +1,659 @@
1
+ # Android Performance Patterns
2
+
3
+ Full code examples for the Android Performance skill. Each section mirrors a
4
+ heading in `SKILL.md`. Load the section named in the guide when implementing or
5
+ reviewing that area.
6
+
7
+ ## Contents
8
+
9
+ - [Baseline Profiles](#baseline-profiles)
10
+ - [Compose Stability](#compose-stability)
11
+ - [Recomposition Debugging](#recomposition-debugging)
12
+ - [Lazy Layout Performance](#lazy-layout-performance)
13
+ - [R8 Optimization](#r8-optimization)
14
+ - [Memory Leak Detection](#memory-leak-detection)
15
+ - [Startup Optimization](#startup-optimization)
16
+ - [Image Loading with Coil](#image-loading-with-coil)
17
+ - [General Performance Patterns](#general-performance-patterns)
18
+
19
+ ## Baseline Profiles
20
+
21
+ ### Setup
22
+
23
+ ```kotlin
24
+ // build.gradle.kts (:app)
25
+ plugins {
26
+ alias(libs.plugins.android.application)
27
+ alias(libs.plugins.baselineprofile)
28
+ }
29
+
30
+ dependencies {
31
+ baselineProfile(project(":baselineprofile"))
32
+ }
33
+
34
+ baselineProfile {
35
+ automaticGenerationDuringBuild = true
36
+ }
37
+ ```
38
+
39
+ ### Generator Module
40
+
41
+ ```kotlin
42
+ // :baselineprofile/build.gradle.kts
43
+ plugins {
44
+ alias(libs.plugins.android.test)
45
+ alias(libs.plugins.baselineprofile)
46
+ }
47
+
48
+ android {
49
+ namespace = "com.example.baselineprofile"
50
+ targetProjectPath = ":app"
51
+ }
52
+
53
+ baselineProfile {
54
+ useConnectedDevices = true
55
+ }
56
+ ```
57
+
58
+ ### Profile Generator
59
+
60
+ ```kotlin
61
+ @RunWith(AndroidJUnit4::class)
62
+ @LargeTest
63
+ class BaselineProfileGenerator {
64
+
65
+ @get:Rule
66
+ val rule = BaselineProfileRule()
67
+
68
+ @Test
69
+ fun generateBaselineProfile() {
70
+ rule.collect(
71
+ packageName = "com.example.app",
72
+ includeInStartupProfile = true,
73
+ ) {
74
+ // Cold start
75
+ pressHome()
76
+ startActivityAndWait()
77
+
78
+ // Critical user journeys
79
+ device.findObject(By.text("Search")).click()
80
+ device.waitForIdle()
81
+
82
+ device.findObject(By.res("search_field")).text = "Istanbul"
83
+ device.waitForIdle()
84
+
85
+ device.findObject(By.res("flight_card")).click()
86
+ device.waitForIdle()
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ ### Generate and Apply
93
+
94
+ ```bash
95
+ # Generate baseline profile
96
+ ./gradlew :app:generateBaselineProfile
97
+
98
+ # Profile is written to app/src/main/baseline-prof.txt
99
+ # Startup profile to app/src/main/startup-prof.txt
100
+ ```
101
+
102
+ ## Compose Stability
103
+
104
+ ### @Immutable and @Stable Annotations
105
+
106
+ ```kotlin
107
+ // Mark a class as truly immutable (all properties never change after construction)
108
+ @Immutable
109
+ data class FlightUiModel(
110
+ val id: String,
111
+ val route: String,
112
+ val price: String,
113
+ val status: String,
114
+ )
115
+
116
+ // Mark a class as stable (Compose can track changes via equals)
117
+ @Stable
118
+ data class FilterState(
119
+ val origin: String = "",
120
+ val destination: String = "",
121
+ val date: LocalDate? = null,
122
+ )
123
+ ```
124
+
125
+ ### Use kotlinx.collections.immutable
126
+
127
+ Replace `List`, `Set`, `Map` with immutable variants for stable parameters:
128
+
129
+ ```kotlin
130
+ // build.gradle.kts
131
+ implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")
132
+
133
+ // Usage
134
+ @Immutable
135
+ data class FlightListUiState(
136
+ val flights: ImmutableList<FlightUiModel> = persistentListOf(),
137
+ val isLoading: Boolean = false,
138
+ )
139
+
140
+ // Convert in ViewModel
141
+ val uiState: StateFlow<FlightListUiState> = repository.getFlights()
142
+ .map { flights ->
143
+ FlightListUiState(
144
+ flights = flights.map(::toUiModel).toImmutableList(),
145
+ )
146
+ }
147
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FlightListUiState())
148
+ ```
149
+
150
+ ### Strong Skipping Mode (Kotlin 2.0+)
151
+
152
+ With the Compose Compiler plugin for Kotlin 2.0+, strong skipping is enabled
153
+ by default. This makes more composables skippable:
154
+
155
+ - Unstable parameters are compared by instance equality (`===`)
156
+ - Lambdas are memoized by default
157
+ - Less need for manual `@Stable` / `@Immutable` annotations
158
+
159
+ Verify in Compose compiler reports:
160
+
161
+ ```properties
162
+ # gradle.properties
163
+ composeCompiler.reportsDestination=build/compose-reports
164
+ composeCompiler.metricsDestination=build/compose-metrics
165
+ ```
166
+
167
+ ```bash
168
+ ./gradlew assembleRelease
169
+ # Check build/compose-reports/app_release-composables.txt for skippability
170
+ ```
171
+
172
+ ## Recomposition Debugging
173
+
174
+ ### Layout Inspector (Android Studio)
175
+
176
+ 1. Run app in debug mode
177
+ 2. Open Layout Inspector: View > Tool Windows > Layout Inspector
178
+ 3. Enable "Show Recomposition Counts" in the toolbar
179
+ 4. Interact with the app and observe which composables recompose
180
+
181
+ High recomposition counts on composables that should not change indicate
182
+ unnecessary invalidation.
183
+
184
+ ### Recomposition Highlighter
185
+
186
+ ```kotlin
187
+ // Debug utility -- remove before release
188
+ @Composable
189
+ fun RecompositionCounter(label: String) {
190
+ val count = remember { mutableIntStateOf(0) }
191
+ count.intValue++
192
+ SideEffect {
193
+ Log.d("Recomposition", "$label: ${count.intValue}")
194
+ }
195
+ }
196
+ ```
197
+
198
+ ### Compose Compiler Metrics
199
+
200
+ Generate stability reports to identify unstable composables:
201
+
202
+ ```bash
203
+ ./gradlew assembleRelease \
204
+ -PcomposeCompiler.reportsDestination=build/compose-reports \
205
+ -PcomposeCompiler.metricsDestination=build/compose-metrics
206
+ ```
207
+
208
+ Key files:
209
+ - `*-composables.txt`: Lists all composables with restartable/skippable status
210
+ - `*-classes.txt`: Lists all classes with stability status
211
+ - `*-module.json`: Summary metrics
212
+
213
+ Look for composables marked `restartable` but NOT `skippable` -- these are
214
+ recomposition hotspots.
215
+
216
+ ## Lazy Layout Performance
217
+
218
+ ### LazyColumn/LazyRow Keys
219
+
220
+ Always provide stable keys for lazy layouts. Without keys, Compose cannot
221
+ efficiently diff items.
222
+
223
+ ```kotlin
224
+ // CORRECT: stable key from item ID
225
+ LazyColumn {
226
+ items(
227
+ items = flights,
228
+ key = { flight -> flight.id },
229
+ ) { flight ->
230
+ FlightCard(flight = flight)
231
+ }
232
+ }
233
+
234
+ // WRONG: no key -- full list diff on every change
235
+ LazyColumn {
236
+ items(flights) { flight ->
237
+ FlightCard(flight = flight)
238
+ }
239
+ }
240
+ ```
241
+
242
+ ### contentType for Heterogeneous Lists
243
+
244
+ ```kotlin
245
+ LazyColumn {
246
+ items(
247
+ items = feedItems,
248
+ key = { it.id },
249
+ contentType = { item ->
250
+ when (item) {
251
+ is FeedItem.Flight -> "flight"
252
+ is FeedItem.Hotel -> "hotel"
253
+ is FeedItem.Ad -> "ad"
254
+ }
255
+ },
256
+ ) { item ->
257
+ when (item) {
258
+ is FeedItem.Flight -> FlightCard(item)
259
+ is FeedItem.Hotel -> HotelCard(item)
260
+ is FeedItem.Ad -> AdBanner(item)
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ `contentType` enables Compose to reuse view holders of the same type,
267
+ similar to RecyclerView's view type system.
268
+
269
+ ### Avoid Heavy Computation in Item Scope
270
+
271
+ ```kotlin
272
+ // WRONG: formatting on every recomposition
273
+ items(flights, key = { it.id }) { flight ->
274
+ val formatted = SimpleDateFormat("HH:mm", Locale.getDefault())
275
+ .format(Date(flight.departureTime))
276
+ Text(formatted)
277
+ }
278
+
279
+ // CORRECT: precompute in ViewModel or UiModel
280
+ data class FlightUiModel(
281
+ val id: String,
282
+ val formattedDeparture: String, // precomputed
283
+ )
284
+ ```
285
+
286
+ ### Prefetch Configuration
287
+
288
+ ```kotlin
289
+ LazyColumn(
290
+ state = rememberLazyListState(),
291
+ // Default prefetch is usually sufficient
292
+ // For custom behavior:
293
+ flingBehavior = ScrollableDefaults.flingBehavior(),
294
+ ) {
295
+ items(flights, key = { it.id }) { flight ->
296
+ FlightCard(flight = flight)
297
+ }
298
+ }
299
+ ```
300
+
301
+ ## R8 Optimization
302
+
303
+ ### Enable Full Optimization
304
+
305
+ ```kotlin
306
+ android {
307
+ buildTypes {
308
+ release {
309
+ isMinifyEnabled = true
310
+ isShrinkResources = true
311
+ proguardFiles(
312
+ getDefaultProguardFile("proguard-android-optimize.txt"),
313
+ "proguard-rules.pro",
314
+ )
315
+ }
316
+ }
317
+ }
318
+ ```
319
+
320
+ ### R8 Full Mode
321
+
322
+ R8 full mode enables more aggressive optimizations:
323
+
324
+ ```properties
325
+ # gradle.properties
326
+ android.enableR8.fullMode=true
327
+ ```
328
+
329
+ Full mode may require additional keep rules for reflection-based code.
330
+
331
+ ### Measuring R8 Impact
332
+
333
+ ```bash
334
+ # Before R8 (debug APK)
335
+ ./gradlew assembleDebug
336
+ ls -la app/build/outputs/apk/debug/app-debug.apk
337
+
338
+ # After R8 (release APK)
339
+ ./gradlew assembleRelease
340
+ ls -la app/build/outputs/apk/release/app-release.apk
341
+
342
+ # Compare sizes
343
+ ```
344
+
345
+ ## Memory Leak Detection
346
+
347
+ ### LeakCanary Setup
348
+
349
+ ```kotlin
350
+ // build.gradle.kts
351
+ debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
352
+ ```
353
+
354
+ LeakCanary runs automatically in debug builds. It detects:
355
+ - Activity leaks
356
+ - Fragment leaks
357
+ - ViewModel leaks
358
+ - Service leaks
359
+ - Custom watched objects
360
+
361
+ ### Common Leak Patterns
362
+
363
+ ```kotlin
364
+ // LEAK: Activity reference in singleton
365
+ object Analytics {
366
+ private var context: Context? = null // holds Activity reference
367
+ fun init(context: Context) { this.context = context }
368
+ }
369
+
370
+ // FIX: Use application context
371
+ object Analytics {
372
+ private var context: Context? = null
373
+ fun init(context: Context) { this.context = context.applicationContext }
374
+ }
375
+
376
+ // LEAK: Coroutine scope outlives lifecycle
377
+ class MyActivity : ComponentActivity() {
378
+ init {
379
+ GlobalScope.launch { // outlives Activity
380
+ // long-running work
381
+ }
382
+ }
383
+ }
384
+
385
+ // FIX: Use lifecycleScope
386
+ class MyActivity : ComponentActivity() {
387
+ override fun onCreate(savedInstanceState: Bundle?) {
388
+ super.onCreate(savedInstanceState)
389
+ lifecycleScope.launch {
390
+ // cancelled when Activity is destroyed
391
+ }
392
+ }
393
+ }
394
+
395
+ // LEAK: Anonymous inner class holds reference
396
+ class MyViewModel : ViewModel() {
397
+ val callback = object : Callback {
398
+ override fun onResult(data: Data) {
399
+ // this holds implicit reference to MyViewModel
400
+ }
401
+ }
402
+ }
403
+ ```
404
+
405
+ ### Manual Object Watching
406
+
407
+ ```kotlin
408
+ // Watch custom objects for leaks
409
+ val watcher = LeakCanary.objectWatcher
410
+ watcher.expectWeaklyReachable(myObject, "MyObject should be GC'd")
411
+ ```
412
+
413
+ ## Startup Optimization
414
+
415
+ ### Macrobenchmark for Startup Tracing
416
+
417
+ ```kotlin
418
+ @RunWith(AndroidJUnit4::class)
419
+ class StartupBenchmark {
420
+
421
+ @get:Rule
422
+ val benchmarkRule = MacrobenchmarkRule()
423
+
424
+ @Test
425
+ fun startupCompilation() {
426
+ benchmarkRule.measureRepeated(
427
+ packageName = "com.example.app",
428
+ metrics = listOf(StartupTimingMetric()),
429
+ iterations = 5,
430
+ startupMode = StartupMode.COLD,
431
+ ) {
432
+ pressHome()
433
+ startActivityAndWait()
434
+ }
435
+ }
436
+
437
+ @Test
438
+ fun startupWithScrolling() {
439
+ benchmarkRule.measureRepeated(
440
+ packageName = "com.example.app",
441
+ metrics = listOf(
442
+ StartupTimingMetric(),
443
+ FrameTimingMetric(),
444
+ ),
445
+ iterations = 5,
446
+ startupMode = StartupMode.COLD,
447
+ ) {
448
+ pressHome()
449
+ startActivityAndWait()
450
+
451
+ val list = device.findObject(By.res("flight_list"))
452
+ list.setGestureMargin(device.displayWidth / 5)
453
+ list.fling(Direction.DOWN)
454
+ device.waitForIdle()
455
+ }
456
+ }
457
+ }
458
+ ```
459
+
460
+ ### Startup Best Practices
461
+
462
+ ```kotlin
463
+ @HiltAndroidApp
464
+ class MyApplication : Application() {
465
+ override fun onCreate() {
466
+ super.onCreate()
467
+ // Only initialize essentials here
468
+ // Defer non-critical init to background
469
+ }
470
+ }
471
+
472
+ // Use App Startup library for lazy initialization
473
+ class AnalyticsInitializer : Initializer<Analytics> {
474
+ override fun create(context: Context): Analytics {
475
+ return Analytics.init(context)
476
+ }
477
+
478
+ override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
479
+ }
480
+ ```
481
+
482
+ ### Defer Heavy Initialization
483
+
484
+ ```kotlin
485
+ // WRONG: blocking startup
486
+ class MyApplication : Application() {
487
+ override fun onCreate() {
488
+ super.onCreate()
489
+ Database.initialize(this) // slow
490
+ ImageLoader.setup(this) // slow
491
+ CrashReporting.setup(this) // slow
492
+ }
493
+ }
494
+
495
+ // CORRECT: defer to background
496
+ class MyApplication : Application() {
497
+ override fun onCreate() {
498
+ super.onCreate()
499
+ ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) {
500
+ Database.initialize(this@MyApplication)
501
+ ImageLoader.setup(this@MyApplication)
502
+ }
503
+ // Crash reporting can stay on main (usually lightweight)
504
+ CrashReporting.setup(this)
505
+ }
506
+ }
507
+ ```
508
+
509
+ ## Image Loading with Coil
510
+
511
+ ### Basic Setup
512
+
513
+ ```kotlin
514
+ // build.gradle.kts
515
+ implementation("io.coil-kt:coil-compose:2.7.0")
516
+
517
+ // Composable
518
+ @Composable
519
+ fun FlightImage(imageUrl: String, modifier: Modifier = Modifier) {
520
+ AsyncImage(
521
+ model = ImageRequest.Builder(LocalContext.current)
522
+ .data(imageUrl)
523
+ .crossfade(true)
524
+ .memoryCacheKey(imageUrl)
525
+ .diskCacheKey(imageUrl)
526
+ .build(),
527
+ contentDescription = "Flight image",
528
+ modifier = modifier,
529
+ contentScale = ContentScale.Crop,
530
+ )
531
+ }
532
+ ```
533
+
534
+ ### Global Image Loader Configuration
535
+
536
+ ```kotlin
537
+ @HiltAndroidApp
538
+ class MyApplication : Application(), ImageLoaderFactory {
539
+
540
+ override fun newImageLoader(): ImageLoader =
541
+ ImageLoader.Builder(this)
542
+ .memoryCache {
543
+ MemoryCache.Builder(this)
544
+ .maxSizePercent(0.25) // 25% of app memory
545
+ .build()
546
+ }
547
+ .diskCache {
548
+ DiskCache.Builder()
549
+ .directory(cacheDir.resolve("image_cache"))
550
+ .maxSizePercent(0.02) // 2% of disk
551
+ .build()
552
+ }
553
+ .crossfade(true)
554
+ .respectCacheHeaders(true)
555
+ .build()
556
+ }
557
+ ```
558
+
559
+ ### Image Size Optimization
560
+
561
+ ```kotlin
562
+ // Downscale to view size -- prevents loading full-resolution images
563
+ AsyncImage(
564
+ model = ImageRequest.Builder(LocalContext.current)
565
+ .data(imageUrl)
566
+ .size(Size(200, 200)) // Request specific size
567
+ .scale(Scale.FILL)
568
+ .build(),
569
+ contentDescription = null,
570
+ modifier = Modifier.size(100.dp), // Display size
571
+ )
572
+ ```
573
+
574
+ ### Placeholder and Error States
575
+
576
+ ```kotlin
577
+ AsyncImage(
578
+ model = imageUrl,
579
+ contentDescription = "Airline logo",
580
+ placeholder = painterResource(R.drawable.placeholder_airline),
581
+ error = painterResource(R.drawable.error_airline),
582
+ fallback = painterResource(R.drawable.default_airline),
583
+ modifier = Modifier
584
+ .size(48.dp)
585
+ .clip(CircleShape),
586
+ )
587
+ ```
588
+
589
+ ## General Performance Patterns
590
+
591
+ ### derivedStateOf for Expensive Computations
592
+
593
+ ```kotlin
594
+ @Composable
595
+ fun FlightList(flights: List<Flight>) {
596
+ val listState = rememberLazyListState()
597
+
598
+ // Only recalculates when the derived condition changes
599
+ val showScrollToTop by remember {
600
+ derivedStateOf { listState.firstVisibleItemIndex > 5 }
601
+ }
602
+
603
+ Box {
604
+ LazyColumn(state = listState) {
605
+ items(flights, key = { it.id }) { FlightCard(it) }
606
+ }
607
+
608
+ if (showScrollToTop) {
609
+ FloatingActionButton(
610
+ onClick = { /* scroll to top */ },
611
+ modifier = Modifier.align(Alignment.BottomEnd),
612
+ ) {
613
+ Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
614
+ }
615
+ }
616
+ }
617
+ }
618
+ ```
619
+
620
+ ### remember for Expensive Objects
621
+
622
+ ```kotlin
623
+ // WRONG: recreated every recomposition
624
+ @Composable
625
+ fun DateDisplay(timestamp: Long) {
626
+ val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
627
+ Text(formatter.format(Date(timestamp)))
628
+ }
629
+
630
+ // CORRECT: remembered across recompositions
631
+ @Composable
632
+ fun DateDisplay(timestamp: Long) {
633
+ val formatter = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
634
+ Text(formatter.format(Date(timestamp)))
635
+ }
636
+ ```
637
+
638
+ ### Debounce for Search
639
+
640
+ ```kotlin
641
+ @HiltViewModel
642
+ class SearchViewModel @Inject constructor(
643
+ private val searchUseCase: SearchFlightsUseCase,
644
+ ) : ViewModel() {
645
+
646
+ private val searchQuery = MutableStateFlow("")
647
+
648
+ val searchResults: StateFlow<List<Flight>> = searchQuery
649
+ .debounce(300) // Wait 300ms after last keystroke
650
+ .distinctUntilChanged()
651
+ .filter { it.length >= 2 }
652
+ .flatMapLatest { query -> searchUseCase(query) }
653
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
654
+
655
+ fun onQueryChange(query: String) {
656
+ searchQuery.value = query
657
+ }
658
+ }
659
+ ```