create-cmp-cli 0.8.0 → 0.10.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 (85) hide show
  1. package/README.md +83 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +97 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +213 -47
  12. package/template/README.md +32 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. package/template/specs/intent.md +50 -0
@@ -0,0 +1,105 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.Arrangement
4
+ import androidx.compose.foundation.layout.Box
5
+ import androidx.compose.foundation.layout.Column
6
+ import androidx.compose.foundation.layout.fillMaxSize
7
+ import androidx.compose.material3.CircularProgressIndicator
8
+ import androidx.compose.runtime.Composable
9
+ import androidx.compose.ui.Alignment
10
+ import androidx.compose.ui.Modifier
11
+ import androidx.compose.ui.semantics.contentDescription
12
+ import androidx.compose.ui.semantics.semantics
13
+ import androidx.compose.ui.semantics.testTag
14
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
15
+
16
+ /**
17
+ * The state dispatcher for any data-backed screen: folds a [ContentUiState] into one of
18
+ * four rendered arms — loading, error, empty, content. The container owns the three
19
+ * non-content arms' UI and their derived testTags; the screen supplies only its content
20
+ * shape in the trailing slot. Every screen that loads data uses it; a feature whose
21
+ * state machine genuinely outgrows the four arms defines its own sealed type and skips
22
+ * the container — that divergence is visible in review.
23
+ *
24
+ * @param state Current arm. The fold is exhaustive; exactly one arm renders.
25
+ * @param screenTag Feature slug ("home"). Required, not defaulted: tests, golden trees,
26
+ * and E2E flows all key on the derived tags `<screenTag>_loading` / `_error` /
27
+ * `_retry` / `_empty`, so tagging cannot be left to each caller.
28
+ * @param onRetry Non-null renders a retry control (`<screenTag>_retry`) in the default
29
+ * error arm.
30
+ * @param loading Loading slot; defaults to a list-shaped skeleton
31
+ * (`ContentStateDefaults.ListSkeleton`).
32
+ * @param error Error slot; receives the message carried by [ContentUiState.Error].
33
+ * @param empty Empty slot; defaults to [EmptyState] with its generic copy.
34
+ * @param content Content arm; receives the loaded data.
35
+ */
36
+ @Composable
37
+ fun <T> ContentStateContainer(
38
+ state: ContentUiState<T>,
39
+ screenTag: String,
40
+ modifier: Modifier = Modifier,
41
+ onRetry: (() -> Unit)? = null,
42
+ loading: @Composable () -> Unit = { ContentStateDefaults.ListSkeleton(screenTag) },
43
+ error: @Composable (message: String) -> Unit = { ErrorState(message = it, screenTag = screenTag, onRetry = onRetry) },
44
+ empty: @Composable () -> Unit = { EmptyState(screenTag = screenTag) },
45
+ content: @Composable (data: T) -> Unit,
46
+ ) {
47
+ Box(modifier.fillMaxSize()) {
48
+ when (state) {
49
+ is ContentUiState.Loading -> loading()
50
+ is ContentUiState.Error -> error(state.message)
51
+ is ContentUiState.Empty -> empty()
52
+ is ContentUiState.Content -> content(state.data)
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Default slot implementations for [ContentStateContainer]. */
58
+ object ContentStateDefaults {
59
+
60
+ /**
61
+ * The default loading slot: skeleton rows shaped like [ListItemCard], so the loaded
62
+ * list replaces them without a layout jump. The container node carries the
63
+ * `<screenTag>_loading` tag and a "Loading" `contentDescription`; the bars
64
+ * themselves are decorative and stay semantics-silent.
65
+ *
66
+ * @param screenTag Feature slug; tags the container `<screenTag>_loading`.
67
+ * @param rows Skeleton rows to render while loading.
68
+ */
69
+ @Composable
70
+ fun ListSkeleton(screenTag: String, rows: Int = 3) {
71
+ Column(
72
+ modifier = Modifier
73
+ .fillMaxSize()
74
+ .semantics {
75
+ testTag = "${screenTag}_loading"
76
+ contentDescription = "Loading"
77
+ },
78
+ verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard),
79
+ ) {
80
+ repeat(rows) { ListItemSkeleton() }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * A centered spinner, for waits that are not content-shaped (a short, single-value
86
+ * operation where a skeleton would promise the wrong layout). Carries the same
87
+ * `<screenTag>_loading` tag as the skeleton.
88
+ *
89
+ * @param screenTag Feature slug; tags the container `<screenTag>_loading`.
90
+ */
91
+ @Composable
92
+ fun Spinner(screenTag: String) {
93
+ Box(
94
+ modifier = Modifier
95
+ .fillMaxSize()
96
+ .semantics {
97
+ testTag = "${screenTag}_loading"
98
+ contentDescription = "Loading"
99
+ },
100
+ contentAlignment = Alignment.Center,
101
+ ) {
102
+ CircularProgressIndicator()
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,18 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ /**
4
+ * The four-way lifecycle of a data-backed screen: Loading, Error, Empty, Content.
5
+ * Sealed so a `when` over it is exhaustive — a screen cannot forget a state, and cannot
6
+ * render two at once. ViewModels fold repository results into this type;
7
+ * [ContentStateContainer] renders it.
8
+ */
9
+ sealed interface ContentUiState<out T> {
10
+ data object Loading : ContentUiState<Nothing>
11
+ data class Error(val message: String) : ContentUiState<Nothing>
12
+ data object Empty : ContentUiState<Nothing>
13
+ data class Content<out T>(val data: T) : ContentUiState<T>
14
+ }
15
+
16
+ /** ViewModel-side helper: the Empty/Content decision made once, not per screen. */
17
+ fun <E> List<E>.toContentState(): ContentUiState<List<E>> =
18
+ if (isEmpty()) ContentUiState.Empty else ContentUiState.Content(this)
@@ -0,0 +1,58 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.Box
4
+ import androidx.compose.foundation.layout.Column
5
+ import androidx.compose.foundation.layout.fillMaxSize
6
+ import androidx.compose.foundation.layout.padding
7
+ import androidx.compose.material3.MaterialTheme
8
+ import androidx.compose.material3.Text
9
+ import androidx.compose.runtime.Composable
10
+ import androidx.compose.ui.Alignment
11
+ import androidx.compose.ui.Modifier
12
+ import androidx.compose.ui.semantics.semantics
13
+ import androidx.compose.ui.semantics.testTag
14
+ import androidx.compose.ui.unit.dp
15
+
16
+ /**
17
+ * The Empty arm of the four-state contract: shown when a load succeeds with nothing to
18
+ * list. Tags itself `<screenTag>_empty`, so the state is selectable in tests and E2E
19
+ * flows. The default copy is deliberately generic — a shaped feature replaces
20
+ * `title`/`body` with its own domain language.
21
+ *
22
+ * @param screenTag Feature slug; the root is tagged `<screenTag>_empty`.
23
+ * @param title Headline. The default is placeholder copy, meant to be replaced per feature.
24
+ * @param body Optional supporting line under the title.
25
+ * @param action Optional call-to-action below the text — an [AppTextButton], for example.
26
+ */
27
+ @Composable
28
+ fun EmptyState(
29
+ screenTag: String,
30
+ modifier: Modifier = Modifier,
31
+ title: String = "Nothing here yet",
32
+ body: String? = null,
33
+ action: (@Composable () -> Unit)? = null,
34
+ ) {
35
+ Box(
36
+ modifier = modifier.fillMaxSize().semantics { testTag = "${screenTag}_empty" },
37
+ contentAlignment = Alignment.Center,
38
+ ) {
39
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
40
+ Text(
41
+ text = title,
42
+ style = MaterialTheme.typography.bodyLarge,
43
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
44
+ )
45
+ if (body != null) {
46
+ Text(
47
+ text = body,
48
+ style = MaterialTheme.typography.bodyMedium,
49
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
50
+ modifier = Modifier.padding(top = 4.dp),
51
+ )
52
+ }
53
+ if (action != null) {
54
+ Box(Modifier.padding(top = 12.dp)) { action() }
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,52 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.Box
4
+ import androidx.compose.foundation.layout.Column
5
+ import androidx.compose.foundation.layout.fillMaxSize
6
+ import androidx.compose.foundation.layout.padding
7
+ import androidx.compose.material3.MaterialTheme
8
+ import androidx.compose.material3.Text
9
+ import androidx.compose.runtime.Composable
10
+ import androidx.compose.ui.Alignment
11
+ import androidx.compose.ui.Modifier
12
+ import androidx.compose.ui.semantics.semantics
13
+ import androidx.compose.ui.semantics.testTag
14
+ import androidx.compose.ui.unit.dp
15
+
16
+ /**
17
+ * The Error arm of the four-state contract: renders the message and, when a retry
18
+ * handler is supplied, a `<screenTag>_retry` control with a 48 dp touch target. The
19
+ * error state is a rendered, testable affordance — not just a ViewModel arm.
20
+ *
21
+ * @param message User-facing copy. Presentation maps a `DomainError` kind to it; never
22
+ * pass a raw exception string.
23
+ * @param screenTag Feature slug; derives the `<screenTag>_error` and `<screenTag>_retry` tags.
24
+ * @param onRetry Non-null renders the retry control; null renders the message alone.
25
+ */
26
+ @Composable
27
+ fun ErrorState(
28
+ message: String,
29
+ screenTag: String,
30
+ onRetry: (() -> Unit)? = null,
31
+ modifier: Modifier = Modifier,
32
+ ) {
33
+ Box(
34
+ modifier = modifier.fillMaxSize().semantics { testTag = "${screenTag}_error" },
35
+ contentAlignment = Alignment.Center,
36
+ ) {
37
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
38
+ Text(
39
+ text = message,
40
+ style = MaterialTheme.typography.bodyLarge,
41
+ color = MaterialTheme.colorScheme.error,
42
+ )
43
+ if (onRetry != null) {
44
+ AppTextButton(
45
+ text = "Retry",
46
+ onClick = onRetry,
47
+ modifier = Modifier.padding(top = 12.dp).semantics { testTag = "${screenTag}_retry" },
48
+ )
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,77 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.clickable
4
+ import androidx.compose.foundation.layout.Column
5
+ import androidx.compose.foundation.layout.Row
6
+ import androidx.compose.foundation.layout.defaultMinSize
7
+ import androidx.compose.foundation.layout.fillMaxWidth
8
+ import androidx.compose.foundation.layout.padding
9
+ import androidx.compose.material3.MaterialTheme
10
+ import androidx.compose.material3.Surface
11
+ import androidx.compose.material3.Text
12
+ import androidx.compose.runtime.Composable
13
+ import androidx.compose.ui.Alignment
14
+ import androidx.compose.ui.Modifier
15
+ import androidx.compose.ui.unit.dp
16
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
17
+ import __PACKAGE__.presentation.theme.designToken
18
+
19
+ /**
20
+ * The list row: title, optional subtitle, optional leading/trailing slots, on a card
21
+ * surface bound to `RadiusCard`, `ElevationCard`, and `PaddingCard`. The `clickable`
22
+ * sits on the `Surface`, so the whole row is the touch target, with a 48 dp minimum
23
+ * height. Reach for it in any list before hand-rolling a row.
24
+ *
25
+ * @param onClick Row-level click handler; the whole card is the target.
26
+ * @param modifier Per-item testTags go here (`Modifier.testTag("home_item_$id")`) — ids
27
+ * are domain data, so the component does not derive them.
28
+ * @param subtitle Optional second line, in the muted variant color.
29
+ * @param leading Slot before the text column — an icon or avatar.
30
+ * @param trailing Slot after the text column — a chevron or badge.
31
+ */
32
+ @Composable
33
+ fun ListItemCard(
34
+ title: String,
35
+ onClick: () -> Unit,
36
+ modifier: Modifier = Modifier,
37
+ subtitle: String? = null,
38
+ leading: (@Composable () -> Unit)? = null,
39
+ trailing: (@Composable () -> Unit)? = null,
40
+ ) {
41
+ Surface(
42
+ color = MaterialTheme.colorScheme.surface,
43
+ shape = MaterialTheme.shapes.medium,
44
+ tonalElevation = __THEME_PREFIX__Tokens.ElevationCard,
45
+ modifier = modifier
46
+ .fillMaxWidth()
47
+ .defaultMinSize(minHeight = 48.dp)
48
+ .designToken(
49
+ tokens = listOf("RadiusCard", "ElevationCard", "PaddingCard"),
50
+ resolved = mapOf(
51
+ "radius" to "${__THEME_PREFIX__Tokens.RadiusCard.value.toInt()}dp",
52
+ "elevation" to "${__THEME_PREFIX__Tokens.ElevationCard.value.toInt()}dp",
53
+ "padding" to "${__THEME_PREFIX__Tokens.PaddingCard.value.toInt()}dp",
54
+ "color" to "#FFFFFFFF",
55
+ ),
56
+ )
57
+ .clickable(onClick = onClick),
58
+ ) {
59
+ Row(
60
+ modifier = Modifier.padding(__THEME_PREFIX__Tokens.PaddingCard),
61
+ verticalAlignment = Alignment.CenterVertically,
62
+ ) {
63
+ if (leading != null) leading()
64
+ Column(modifier = Modifier.weight(1f)) {
65
+ Text(title, style = MaterialTheme.typography.titleMedium)
66
+ if (subtitle != null) {
67
+ Text(
68
+ subtitle,
69
+ style = MaterialTheme.typography.bodyMedium,
70
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
71
+ )
72
+ }
73
+ }
74
+ if (trailing != null) trailing()
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,47 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.Column
4
+ import androidx.compose.foundation.layout.ColumnScope
5
+ import androidx.compose.foundation.layout.fillMaxSize
6
+ import androidx.compose.foundation.layout.padding
7
+ import androidx.compose.foundation.rememberScrollState
8
+ import androidx.compose.foundation.verticalScroll
9
+ import androidx.compose.runtime.Composable
10
+ import androidx.compose.ui.Modifier
11
+ import androidx.compose.ui.semantics.semantics
12
+ import androidx.compose.ui.semantics.testTag
13
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
14
+ import __PACKAGE__.presentation.theme.designToken
15
+
16
+ /**
17
+ * The page container every screen roots itself in. Owns the tagged root
18
+ * (`<screenTag>_screen`), the page padding from `PaddingPage`, and that token's
19
+ * inspector self-report — the reported value is derived from the token itself, so it
20
+ * cannot drift from the catalog. Screens never hand-roll their root column.
21
+ *
22
+ * @param screenTag Feature slug ("home"); the root is tagged `<screenTag>_screen`.
23
+ * @param scrollable True wraps the column in a vertical scroll. Leave false when the
24
+ * content scrolls itself (a `LazyColumn`) — nesting the two crashes at runtime.
25
+ * @param content Column body, laid out inside the page padding.
26
+ */
27
+ @Composable
28
+ fun ScreenColumn(
29
+ screenTag: String,
30
+ modifier: Modifier = Modifier,
31
+ scrollable: Boolean = false,
32
+ content: @Composable ColumnScope.() -> Unit,
33
+ ) {
34
+ val base = modifier
35
+ .fillMaxSize()
36
+ .semantics { testTag = "${screenTag}_screen" }
37
+ .designToken(
38
+ tokens = listOf("PaddingPage"),
39
+ resolved = mapOf("padding" to "${__THEME_PREFIX__Tokens.PaddingPage.value.toInt()}dp"),
40
+ )
41
+ .padding(__THEME_PREFIX__Tokens.PaddingPage)
42
+
43
+ Column(
44
+ modifier = if (scrollable) base.verticalScroll(rememberScrollState()) else base,
45
+ content = content,
46
+ )
47
+ }
@@ -0,0 +1,90 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.animation.core.LinearEasing
4
+ import androidx.compose.animation.core.RepeatMode
5
+ import androidx.compose.animation.core.animateFloat
6
+ import androidx.compose.animation.core.infiniteRepeatable
7
+ import androidx.compose.animation.core.rememberInfiniteTransition
8
+ import androidx.compose.animation.core.tween
9
+ import androidx.compose.foundation.background
10
+ import androidx.compose.foundation.layout.Box
11
+ import androidx.compose.foundation.layout.Column
12
+ import androidx.compose.foundation.layout.fillMaxWidth
13
+ import androidx.compose.foundation.layout.height
14
+ import androidx.compose.foundation.layout.padding
15
+ import androidx.compose.material3.MaterialTheme
16
+ import androidx.compose.material3.Surface
17
+ import androidx.compose.runtime.Composable
18
+ import androidx.compose.runtime.getValue
19
+ import androidx.compose.ui.Modifier
20
+ import androidx.compose.ui.composed
21
+ import androidx.compose.ui.draw.clip
22
+ import androidx.compose.ui.geometry.Offset
23
+ import androidx.compose.ui.graphics.Brush
24
+ import androidx.compose.ui.unit.dp
25
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
26
+
27
+ /**
28
+ * A left-to-right shimmer sweep: an infinite transition driving a linear-gradient brush.
29
+ * Hand-rolled with zero dependencies — Accompanist's `placeholder` artifact is
30
+ * deprecated and Android-only, so no library option exists for this project's
31
+ * commonMain. Apply to any box that stands in for loading content.
32
+ */
33
+ fun Modifier.shimmer(): Modifier = composed {
34
+ val transition = rememberInfiniteTransition(label = "shimmer")
35
+ val translateX by transition.animateFloat(
36
+ initialValue = -1000f,
37
+ targetValue = 1000f,
38
+ animationSpec = infiniteRepeatable(
39
+ animation = tween(durationMillis = 1200, easing = LinearEasing),
40
+ repeatMode = RepeatMode.Restart,
41
+ ),
42
+ label = "shimmerTranslate",
43
+ )
44
+ background(
45
+ Brush.linearGradient(
46
+ colors = listOf(
47
+ MaterialTheme.colorScheme.surfaceVariant,
48
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
49
+ MaterialTheme.colorScheme.surfaceVariant,
50
+ ),
51
+ start = Offset(translateX, 0f),
52
+ end = Offset(translateX + 400f, 400f),
53
+ ),
54
+ )
55
+ }
56
+
57
+ /**
58
+ * A skeleton row matching [ListItemCard]'s geometry — same shape, elevation, and padding
59
+ * tokens — so the loaded list replaces it without a layout jump. Decorative and
60
+ * semantics-silent by design: the loading container (`ContentStateDefaults.ListSkeleton`)
61
+ * carries the `<screenTag>_loading` tag and the "Loading" `contentDescription`, so
62
+ * assistive tech announces one loading state, not a stack of anonymous bars.
63
+ */
64
+ @Composable
65
+ fun ListItemSkeleton(modifier: Modifier = Modifier) {
66
+ Surface(
67
+ color = MaterialTheme.colorScheme.surface,
68
+ shape = MaterialTheme.shapes.medium,
69
+ tonalElevation = __THEME_PREFIX__Tokens.ElevationCard,
70
+ modifier = modifier.fillMaxWidth(),
71
+ ) {
72
+ Column(Modifier.padding(__THEME_PREFIX__Tokens.PaddingCard)) {
73
+ Box(
74
+ Modifier
75
+ .fillMaxWidth(0.6f)
76
+ .height(16.dp)
77
+ .clip(MaterialTheme.shapes.small)
78
+ .shimmer(),
79
+ )
80
+ Box(
81
+ Modifier
82
+ .padding(top = 8.dp)
83
+ .fillMaxWidth(0.4f)
84
+ .height(12.dp)
85
+ .clip(MaterialTheme.shapes.small)
86
+ .shimmer(),
87
+ )
88
+ }
89
+ }
90
+ }
@@ -4,15 +4,15 @@ import androidx.compose.ui.Modifier
4
4
 
5
5
  /**
6
6
  * Exposes every `Modifier.testTag` in the subtree to the platform's UI-automation layer,
7
- * so Appium/uiautomator id-selectors (and the cmp-test-generated suites) find them on a
8
- * stock build with no extra flags.
7
+ * so id-based E2E selectors (Maestro, Appium, XCUITest) find them on a stock build with
8
+ * no extra flags. Apply once at the root — `AppShell` does — and it covers the whole
9
+ * subtree.
9
10
  *
10
- * - Android actual: `semantics { testTagsAsResourceId = true }` → tags surface as
11
- * `resource-id` in the uiautomator/Appium tree.
12
- * - iOS actual: same semantics flag → tags surface as `accessibilityIdentifier` (XCUITest).
13
- * - Desktop actual: no-op the skiko `ui` artifact has no `testTagsAsResourceId`
14
- * (there is no resource-id concept on desktop; tests use the Compose test APIs directly).
15
- *
16
- * Apply once at the root (AppShell does); it covers the whole subtree.
11
+ * Per-platform actuals:
12
+ * - Android: `semantics { testTagsAsResourceId = true }` — tags surface as `resource-id`
13
+ * in the uiautomator tree.
14
+ * - iOS: the same semantics flag tags surface as `accessibilityIdentifier` (XCUITest).
15
+ * - Desktop: no-op — no resource-id concept exists there; desktop tests use the Compose
16
+ * test APIs directly.
17
17
  */
18
18
  expect fun Modifier.exposeTestTagsForAutomation(): Modifier
@@ -1,18 +1,11 @@
1
1
  package __PACKAGE__.presentation.home
2
2
 
3
- import androidx.compose.foundation.layout.Column
4
- import androidx.compose.foundation.layout.fillMaxSize
5
- import androidx.compose.foundation.layout.padding
6
3
  import androidx.compose.material3.MaterialTheme
7
4
  import androidx.compose.material3.Text
8
- import androidx.compose.material3.TextButton
9
5
  import androidx.compose.runtime.Composable
10
- import androidx.compose.ui.Modifier
11
- import androidx.compose.ui.semantics.semantics
12
- import androidx.compose.ui.semantics.testTag
6
+ import __PACKAGE__.presentation.components.AppHeader
13
7
  import __PACKAGE__.presentation.components.BaseScreen
14
- import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
15
- import __PACKAGE__.presentation.theme.designToken
8
+ import __PACKAGE__.presentation.components.ScreenColumn
16
9
 
17
10
  @Composable
18
11
  fun DetailScreen(
@@ -20,25 +13,10 @@ fun DetailScreen(
20
13
  onBack: () -> Unit,
21
14
  ) {
22
15
  BaseScreen {
23
- Column(
24
- Modifier
25
- .fillMaxSize()
26
- .designToken(
27
- tokens = listOf("PaddingPage"),
28
- resolved = mapOf("padding" to "16dp"),
29
- )
30
- .padding(__THEME_PREFIX__Tokens.PaddingPage),
31
- ) {
32
- TextButton(onClick = onBack, modifier = Modifier.semantics { testTag = "detail_back" }) {
33
- Text("← Back")
34
- }
16
+ ScreenColumn(screenTag = "detail") {
17
+ AppHeader(title = "Detail", screenTag = "detail", onBack = onBack)
35
18
  Text(
36
- "Detail",
37
- style = MaterialTheme.typography.headlineMedium,
38
- modifier = Modifier.semantics { testTag = "detail_title" },
39
- )
40
- Text(
41
- "Item id: $itemId",
19
+ text = "Item id: $itemId",
42
20
  style = MaterialTheme.typography.bodyLarge,
43
21
  color = MaterialTheme.colorScheme.onSurfaceVariant,
44
22
  )
@@ -1,28 +1,19 @@
1
1
  package __PACKAGE__.presentation.home
2
2
 
3
- import androidx.compose.foundation.clickable
4
3
  import androidx.compose.foundation.layout.Arrangement
5
- import androidx.compose.foundation.layout.Box
6
- import androidx.compose.foundation.layout.Column
7
- import androidx.compose.foundation.layout.fillMaxSize
8
- import androidx.compose.foundation.layout.fillMaxWidth
9
- import androidx.compose.foundation.layout.padding
10
4
  import androidx.compose.foundation.lazy.LazyColumn
11
5
  import androidx.compose.foundation.lazy.items
12
- import androidx.compose.material3.CircularProgressIndicator
13
- import androidx.compose.material3.MaterialTheme
14
- import androidx.compose.material3.Surface
15
- import androidx.compose.material3.Text
16
6
  import androidx.compose.runtime.Composable
17
7
  import androidx.compose.runtime.getValue
18
- import androidx.compose.ui.Alignment
19
8
  import androidx.compose.ui.Modifier
20
9
  import androidx.compose.ui.semantics.semantics
21
10
  import androidx.compose.ui.semantics.testTag
22
- import androidx.compose.ui.unit.dp
23
11
  import androidx.lifecycle.compose.collectAsStateWithLifecycle
12
+ import __PACKAGE__.presentation.components.AppHeader
13
+ import __PACKAGE__.presentation.components.ContentStateContainer
14
+ import __PACKAGE__.presentation.components.ListItemCard
15
+ import __PACKAGE__.presentation.components.ScreenColumn
24
16
  import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
25
- import __PACKAGE__.presentation.theme.designToken
26
17
  import org.koin.compose.viewmodel.koinViewModel
27
18
 
28
19
  @Composable
@@ -32,64 +23,17 @@ fun HomeScreen(
32
23
  ) {
33
24
  val state by viewModel.state.collectAsStateWithLifecycle()
34
25
 
35
- Column(
36
- Modifier
37
- .fillMaxSize()
38
- .designToken(
39
- tokens = listOf("PaddingPage"),
40
- resolved = mapOf("padding" to "16dp"),
41
- )
42
- .padding(__THEME_PREFIX__Tokens.PaddingPage),
43
- ) {
44
- Text(
45
- text = "Home",
46
- style = MaterialTheme.typography.headlineMedium,
47
- modifier = Modifier.semantics { testTag = "home_title" }.padding(bottom = 12.dp),
48
- )
49
-
50
- val errorMessage = state.errorMessage
51
- if (state.isLoading) {
52
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
53
- CircularProgressIndicator()
54
- }
55
- } else if (errorMessage != null) {
56
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
57
- Text(
58
- text = errorMessage,
59
- style = MaterialTheme.typography.bodyLarge,
60
- color = MaterialTheme.colorScheme.error,
61
- modifier = Modifier.semantics { testTag = "home_error" },
62
- )
63
- }
64
- } else {
26
+ ScreenColumn(screenTag = "home") {
27
+ AppHeader(title = "Home", screenTag = "home")
28
+ ContentStateContainer(state = state, screenTag = "home", onRetry = viewModel::load) { items ->
65
29
  LazyColumn(verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard)) {
66
- items(state.items, key = { it.id }) { item ->
67
- Surface(
68
- color = MaterialTheme.colorScheme.surface,
69
- shape = MaterialTheme.shapes.medium,
70
- tonalElevation = __THEME_PREFIX__Tokens.ElevationCard,
71
- modifier = Modifier
72
- .fillMaxWidth()
73
- .designToken(
74
- tokens = listOf("RadiusCard", "ElevationCard", "PaddingCard"),
75
- resolved = mapOf(
76
- "radius" to "16dp",
77
- "elevation" to "2dp",
78
- "padding" to "16dp",
79
- "color" to "#FFFFFFFF",
80
- ),
81
- )
82
- .clickable { onItemClick(item.id) },
83
- ) {
84
- Column(Modifier.padding(__THEME_PREFIX__Tokens.PaddingCard)) {
85
- Text(item.title, style = MaterialTheme.typography.titleMedium)
86
- Text(
87
- item.subtitle,
88
- style = MaterialTheme.typography.bodyMedium,
89
- color = MaterialTheme.colorScheme.onSurfaceVariant,
90
- )
91
- }
92
- }
30
+ items(items, key = { it.id }) { item ->
31
+ ListItemCard(
32
+ title = item.title,
33
+ subtitle = item.subtitle,
34
+ onClick = { onItemClick(item.id) },
35
+ modifier = Modifier.semantics { testTag = "home_item_${item.id}" },
36
+ )
93
37
  }
94
38
  }
95
39
  }