create-cmp-cli 0.8.0 → 0.9.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 (74) hide show
  1. package/README.md +67 -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 +91 -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 +196 -48
  12. package/template/README.md +23 -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/InspectorHttpServer.kt +69 -2
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  18. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  19. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  43. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  44. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  45. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  46. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  47. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  48. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  49. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  50. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  51. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  52. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  53. package/template/docs/ARCHITECTURE.md +317 -34
  54. package/template/docs/TESTING.md +6 -5
  55. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  56. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  57. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  58. package/template/qa/approvals.json +42 -0
  59. package/template/qa/approve.mjs +139 -0
  60. package/template/qa/arch-doc.mjs +69 -0
  61. package/template/qa/comment.mjs +76 -0
  62. package/template/qa/comments.json +4 -0
  63. package/template/qa/golden/home.json +3 -3
  64. package/template/qa/lib/approvals.mjs +806 -0
  65. package/template/qa/lib/arch-doc.mjs +451 -0
  66. package/template/qa/lib/comments.mjs +252 -0
  67. package/template/qa/lib/component-stories.mjs +183 -0
  68. package/template/qa/lib/inputs-hash.mjs +5 -1
  69. package/template/qa/scaffold-feature.mjs +184 -67
  70. package/template/qa/setup-hooks.mjs +33 -0
  71. package/template/qa/verify.mjs +118 -9
  72. package/template/specs/app-base.spec.md +44 -7
  73. package/template/specs/home.spec.md +7 -4
  74. package/template/specs/intent.md +50 -0
@@ -0,0 +1,138 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.clickable
5
+ import androidx.compose.foundation.layout.Arrangement
6
+ import androidx.compose.foundation.layout.Box
7
+ import androidx.compose.foundation.layout.Column
8
+ import androidx.compose.foundation.layout.Row
9
+ import androidx.compose.foundation.layout.defaultMinSize
10
+ import androidx.compose.foundation.layout.fillMaxWidth
11
+ import androidx.compose.foundation.layout.height
12
+ import androidx.compose.foundation.layout.navigationBarsPadding
13
+ import androidx.compose.foundation.layout.padding
14
+ import androidx.compose.foundation.layout.size
15
+ import androidx.compose.foundation.shape.RoundedCornerShape
16
+ import androidx.compose.material3.Icon
17
+ import androidx.compose.material3.Text
18
+ import androidx.compose.runtime.Composable
19
+ import androidx.compose.ui.Alignment
20
+ import androidx.compose.ui.Modifier
21
+ import androidx.compose.ui.draw.clip
22
+ import androidx.compose.ui.semantics.semantics
23
+ import androidx.compose.ui.semantics.testTag
24
+ import androidx.compose.ui.text.font.FontWeight
25
+ import androidx.compose.ui.unit.dp
26
+ import androidx.compose.ui.unit.sp
27
+ import __PACKAGE__.presentation.navigation.AppTab
28
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
29
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
30
+ import __PACKAGE__.presentation.theme.designToken
31
+
32
+ /**
33
+ * The bottom tab bar: one item per tab, icon over label. Owns the 48 dp touch targets,
34
+ * the deterministic `nav_<slug>` testTags, token-bound colors, the navigation-bar inset
35
+ * padding, and the `BottomNavHeight` inspector self-report. Selection state stays with
36
+ * the caller; `AppShell` wires it.
37
+ *
38
+ * @param tabs Tabs in display order; each label also derives its item's `nav_*` testTag.
39
+ * @param selectedIndex Index of the selected tab in [tabs].
40
+ * @param onSelect Called with the index of the tapped tab.
41
+ */
42
+ @Composable
43
+ fun AppBottomBar(
44
+ tabs: List<AppTab>,
45
+ selectedIndex: Int,
46
+ onSelect: (Int) -> Unit,
47
+ modifier: Modifier = Modifier,
48
+ ) {
49
+ Column(modifier.fillMaxWidth()) {
50
+ Box(
51
+ Modifier
52
+ .fillMaxWidth()
53
+ .height(1.dp)
54
+ .background(__THEME_PREFIX__Colors.OutlineVariant)
55
+ )
56
+ Row(
57
+ modifier = Modifier
58
+ .fillMaxWidth()
59
+ .background(__THEME_PREFIX__Colors.Surface)
60
+ // Lift tabs above the gesture pill / 3-button nav (maps to iOS safe area).
61
+ .navigationBarsPadding()
62
+ .height(__THEME_PREFIX__Tokens.BottomNavHeight)
63
+ // Inspector: the bottom-nav container self-reports its height token.
64
+ .designToken(
65
+ tokens = listOf("BottomNavHeight"),
66
+ resolved = mapOf("height" to "${__THEME_PREFIX__Tokens.BottomNavHeight.value.toInt()}dp"),
67
+ )
68
+ .semantics { testTag = "app_bottom_nav" }
69
+ .padding(bottom = 8.dp),
70
+ verticalAlignment = Alignment.CenterVertically,
71
+ horizontalArrangement = Arrangement.SpaceEvenly,
72
+ ) {
73
+ tabs.forEachIndexed { index, tab ->
74
+ NavItem(
75
+ label = tab.label,
76
+ selected = selectedIndex == index,
77
+ onClick = { onSelect(index) },
78
+ ) {
79
+ Icon(
80
+ imageVector = tab.icon,
81
+ contentDescription = tab.label,
82
+ tint = if (selectedIndex == index) __THEME_PREFIX__Colors.Primary
83
+ else __THEME_PREFIX__Colors.OnSurfaceVariant.copy(alpha = 0.55f),
84
+ modifier = Modifier.size(24.dp),
85
+ )
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Deterministic automation tag for a nav item: `nav_` + the label lowercased with every
94
+ * non-[a-z0-9] run collapsed to `_` and trimmed (e.g. "My Stuff!" → `nav_my_stuff`).
95
+ * Must mirror `navSlug` in create-cmp's engine (src/lib/tabs.mjs), which generates
96
+ * `qa/e2e/smoke.yaml`'s id selectors from the configured tabs — keep the two in sync.
97
+ */
98
+ private fun navItemTag(label: String): String =
99
+ "nav_" + label.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
100
+
101
+ /**
102
+ * A single tab item: icon over label, a 48 dp minimum touch target, tagged from its
103
+ * label via [navItemTag]. Internal (not private) so the component story
104
+ * (`component.nav-item`) can render it in isolation; absent from the public API surface.
105
+ *
106
+ * @param selected True renders the item in the selected treatment (primary color, bold label).
107
+ * @param icon Icon slot, rendered above the label.
108
+ */
109
+ @Composable
110
+ internal fun NavItem(
111
+ label: String,
112
+ selected: Boolean,
113
+ onClick: () -> Unit,
114
+ icon: @Composable () -> Unit,
115
+ ) {
116
+ Column(
117
+ modifier = Modifier
118
+ .clip(RoundedCornerShape(8.dp))
119
+ .clickable(onClick = onClick)
120
+ // a11y: guarantee the 48dp minimum touch target regardless of label width
121
+ // (the inspector's audit_a11y flags anything smaller).
122
+ .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
123
+ // Durable selection handle (tests/E2E select by testTag, never display text).
124
+ .semantics { testTag = navItemTag(label) }
125
+ .padding(horizontal = 8.dp, vertical = 4.dp),
126
+ horizontalAlignment = Alignment.CenterHorizontally,
127
+ verticalArrangement = Arrangement.Center,
128
+ ) {
129
+ icon()
130
+ Text(
131
+ text = label,
132
+ fontSize = 10.sp,
133
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
134
+ color = if (selected) __THEME_PREFIX__Colors.Primary else __THEME_PREFIX__Colors.OnSurfaceVariant,
135
+ modifier = Modifier.padding(top = 4.dp),
136
+ )
137
+ }
138
+ }
@@ -0,0 +1,56 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.sizeIn
4
+ import androidx.compose.material3.Button
5
+ import androidx.compose.material3.Text
6
+ import androidx.compose.material3.TextButton
7
+ import androidx.compose.runtime.Composable
8
+ import androidx.compose.ui.Modifier
9
+ import androidx.compose.ui.unit.dp
10
+
11
+ /**
12
+ * The filled call-to-action button: M3 `Button` with a 48 dp minimum touch target
13
+ * applied. Stock M3 buttons sit below that floor by default; wrapping them here clears
14
+ * WCAG 2.2 SC 2.5.8 and the harness's `audit_a11y` bar once, for every call site.
15
+ */
16
+ @Composable
17
+ fun AppPrimaryButton(
18
+ text: String,
19
+ onClick: () -> Unit,
20
+ modifier: Modifier = Modifier,
21
+ enabled: Boolean = true,
22
+ ) {
23
+ Button(
24
+ onClick = onClick,
25
+ enabled = enabled,
26
+ modifier = modifier.sizeIn(minWidth = AppButtonDefaults.MinTouchTarget, minHeight = AppButtonDefaults.MinTouchTarget),
27
+ ) {
28
+ Text(text)
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The low-emphasis text button, with the same 48 dp floor as [AppPrimaryButton]. These
34
+ * two are the registry's only buttons — a new variant (icon, loading, destructive, FAB)
35
+ * is a registry addition a human approves, not a local tweak.
36
+ */
37
+ @Composable
38
+ fun AppTextButton(
39
+ text: String,
40
+ onClick: () -> Unit,
41
+ modifier: Modifier = Modifier,
42
+ enabled: Boolean = true,
43
+ ) {
44
+ TextButton(
45
+ onClick = onClick,
46
+ enabled = enabled,
47
+ modifier = modifier.sizeIn(minWidth = AppButtonDefaults.MinTouchTarget, minHeight = AppButtonDefaults.MinTouchTarget),
48
+ ) {
49
+ Text(text)
50
+ }
51
+ }
52
+
53
+ /** Shared button constants, following the `ComponentDefaults` naming convention. */
54
+ object AppButtonDefaults {
55
+ val MinTouchTarget = 48.dp
56
+ }
@@ -0,0 +1,54 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.Row
4
+ import androidx.compose.foundation.layout.RowScope
5
+ import androidx.compose.foundation.layout.fillMaxWidth
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 screen header: a headline row with an optional back affordance and a trailing
18
+ * actions slot, tagged `<screenTag>_title` and `<screenTag>_back`. Deliberately not an
19
+ * M3 `TopAppBar` — no scroll behaviors, no center-aligned variants, no window-inset
20
+ * handling (`BaseScreen` owns insets, SHELL-03). A collapsing toolbar would be a
21
+ * registry addition, not a default.
22
+ *
23
+ * @param title Headline text, rendered in `headlineMedium`.
24
+ * @param screenTag Feature slug; derives the `<screenTag>_title` and `<screenTag>_back` tags.
25
+ * @param onBack Non-null renders a 48 dp back affordance left of the title.
26
+ * @param actions Trailing slot at the row's end, for per-screen controls.
27
+ */
28
+ @Composable
29
+ fun AppHeader(
30
+ title: String,
31
+ screenTag: String,
32
+ modifier: Modifier = Modifier,
33
+ onBack: (() -> Unit)? = null,
34
+ actions: @Composable RowScope.() -> Unit = {},
35
+ ) {
36
+ Row(
37
+ modifier = modifier.fillMaxWidth().padding(bottom = 12.dp),
38
+ verticalAlignment = Alignment.CenterVertically,
39
+ ) {
40
+ if (onBack != null) {
41
+ AppTextButton(
42
+ text = "← Back",
43
+ onClick = onBack,
44
+ modifier = Modifier.semantics { testTag = "${screenTag}_back" },
45
+ )
46
+ }
47
+ Text(
48
+ text = title,
49
+ style = MaterialTheme.typography.headlineMedium,
50
+ modifier = Modifier.weight(1f).semantics { testTag = "${screenTag}_title" },
51
+ )
52
+ actions()
53
+ }
54
+ }
@@ -15,15 +15,23 @@ import androidx.compose.ui.graphics.Color
15
15
  import __PACKAGE__.presentation.theme.designToken
16
16
 
17
17
  /**
18
- * The insets moat, pre-solved. Every screen wraps its content in [BaseScreen] instead of
19
- * re-deriving edge-to-edge padding. The Activity is edge-to-edge (transparent system bars);
20
- * this Scaffold owns the status-bar / navigation-bar insets in shared code, which also maps
21
- * to iOS safe areas under Compose Multiplatform.
18
+ * The edge-to-edge scaffold that owns system-bar insets in shared code. The Activity
19
+ * draws behind transparent system bars; this component applies status-bar and
20
+ * navigation-bar padding once (mapping to iOS safe areas under Compose Multiplatform),
21
+ * consumes what it applies to prevent doubled padding, and self-reports the applied
22
+ * inset facts to the inspector. Screens wrap their content in it instead of re-deriving
23
+ * insets.
22
24
  *
23
- * - [topBar] / [bottomBar] draw edge-to-edge (e.g. a nav bar that bleeds behind the gesture
24
- * pill) and are responsible for their own inset padding.
25
- * - The content lambda receives padding already accounting for any bars; by default the body
26
- * gets status + navigation bar padding so plain screens are safe with zero ceremony.
25
+ * @param containerColor Background color; `Color.Unspecified` resolves to the theme background.
26
+ * @param applyStatusBarPadding False lets the body draw under the status bar, for
27
+ * full-bleed content that handles the top inset itself.
28
+ * @param applyNavBarPadding False lets the body draw under the navigation bar set it
29
+ * when a bottom bar owns that inset instead.
30
+ * @param topBar Draws edge-to-edge and is responsible for its own inset padding.
31
+ * @param bottomBar Draws edge-to-edge and is responsible for its own inset padding
32
+ * (e.g. a nav bar that bleeds behind the gesture pill).
33
+ * @param content Screen body. Its padding is already applied by the wrapper; the
34
+ * `PaddingValues` are passed through for callers that need the raw values.
27
35
  */
28
36
  @Composable
29
37
  fun BaseScreen(
@@ -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
+ }