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.
- package/README.md +67 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +91 -4
- package/src/scaffold.mjs +52 -1
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +196 -48
- package/template/README.md +23 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +317 -34
- package/template/docs/TESTING.md +6 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/approvals.mjs +806 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +118 -9
- package/template/specs/app-base.spec.md +44 -7
- package/template/specs/home.spec.md +7 -4
- 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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* to
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
+
}
|