create-cmp-cli 0.7.1 → 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 +72 -11
- package/llms.txt +6 -2
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +8 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/registry.mjs +15 -2
- package/src/lib/tabs.mjs +91 -4
- package/src/lib/upgrade.mjs +49 -5
- package/src/scaffold.mjs +52 -1
- package/src/versions/candidates.json +4 -0
- package/src/versions/registry.json +88 -0
- 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,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
|
+
}
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt
ADDED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
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
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
"
|
|
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
|
)
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
modifier = Modifier
|
|
72
|
-
|
|
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
|
}
|