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
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
|
}
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt
CHANGED
|
@@ -2,25 +2,35 @@ package __PACKAGE__.presentation.home
|
|
|
2
2
|
|
|
3
3
|
import androidx.lifecycle.ViewModel
|
|
4
4
|
import androidx.lifecycle.viewModelScope
|
|
5
|
+
import __PACKAGE__.domain.model.DomainError
|
|
5
6
|
import __PACKAGE__.domain.model.Item
|
|
7
|
+
import __PACKAGE__.domain.result.AppResult
|
|
6
8
|
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
9
|
+
import __PACKAGE__.presentation.components.ContentUiState
|
|
10
|
+
import __PACKAGE__.presentation.components.toContentState
|
|
7
11
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
8
12
|
import kotlinx.coroutines.flow.StateFlow
|
|
9
13
|
import kotlinx.coroutines.flow.asStateFlow
|
|
10
14
|
import kotlinx.coroutines.launch
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* No `try`/`catch` here — ever (ARCH-07). Failures arrive as typed [AppResult.Failure]
|
|
18
|
+
* values from the use case; the ViewModel folds over the result and maps [DomainError]
|
|
19
|
+
* KINDS to user-facing copy. A `CancellationException` thrown while suspended simply
|
|
20
|
+
* cancels this coroutine (structured concurrency) — it never becomes an error state.
|
|
21
|
+
*
|
|
22
|
+
* The state machine is the shared [ContentUiState] (`presentation/components`) — the
|
|
23
|
+
* generalization of this feature's pre-generalization per-feature sealed state (which
|
|
24
|
+
* EH-1 landed): same four arms (Loading/Content/Empty/Error), same fold, made generic once
|
|
25
|
+
* so `ContentStateContainer` can own the non-content arms' rendering instead of every
|
|
26
|
+
* screen hand-folding its own copy.
|
|
27
|
+
*/
|
|
18
28
|
class HomeViewModel(
|
|
19
29
|
private val getItems: GetItemsUseCase,
|
|
20
30
|
) : ViewModel() {
|
|
21
31
|
|
|
22
|
-
private val _state = MutableStateFlow(
|
|
23
|
-
val state: StateFlow<
|
|
32
|
+
private val _state = MutableStateFlow<ContentUiState<List<Item>>>(ContentUiState.Loading)
|
|
33
|
+
val state: StateFlow<ContentUiState<List<Item>>> = _state.asStateFlow()
|
|
24
34
|
|
|
25
35
|
init {
|
|
26
36
|
load()
|
|
@@ -28,12 +38,22 @@ class HomeViewModel(
|
|
|
28
38
|
|
|
29
39
|
fun load() {
|
|
30
40
|
viewModelScope.launch {
|
|
31
|
-
_state.value =
|
|
32
|
-
_state.value =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
HomeUiState(isLoading = false, errorMessage = e.message ?: "Something went wrong")
|
|
41
|
+
_state.value = ContentUiState.Loading
|
|
42
|
+
_state.value = when (val result = getItems()) {
|
|
43
|
+
is AppResult.Success -> result.value.toContentState()
|
|
44
|
+
is AppResult.Failure -> ContentUiState.Error(result.error.toUserMessage())
|
|
36
45
|
}
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Presentation owns user-facing copy: error KINDS become strings here, next to the screen
|
|
52
|
+
* that shows them. A raw `Throwable.message` never reaches the UI — the domain carries no
|
|
53
|
+
* display text at all.
|
|
54
|
+
*/
|
|
55
|
+
internal fun DomainError.toUserMessage(): String = when (this) {
|
|
56
|
+
DomainError.Network -> "Can't reach the server. Check your connection and try again."
|
|
57
|
+
DomainError.NotFound -> "That content isn't available."
|
|
58
|
+
is DomainError.Unexpected -> "Something went wrong. Please try again."
|
|
59
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package __PACKAGE__.presentation.navigation
|
|
2
2
|
|
|
3
3
|
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
4
5
|
import androidx.navigation.NavType
|
|
5
6
|
import androidx.navigation.compose.NavHost
|
|
6
7
|
import androidx.navigation.compose.composable
|
|
@@ -16,6 +17,18 @@ import __PACKAGE__.presentation.profile.ProfileScreen
|
|
|
16
17
|
fun AppNavHost() {
|
|
17
18
|
val navController = rememberNavController()
|
|
18
19
|
|
|
20
|
+
// Report every back-stack change to the common inspection seam — a no-op unless the
|
|
21
|
+
// androidDebug inspector registered a listener (see NavInspectionHook.kt). Best-effort:
|
|
22
|
+
// `currentBackStack` is a live snapshot, not a durable history.
|
|
23
|
+
LaunchedEffect(navController) {
|
|
24
|
+
navController.currentBackStack.collect { stack ->
|
|
25
|
+
NavInspectionHook.listener?.invoke(
|
|
26
|
+
navController.currentDestination?.route,
|
|
27
|
+
stack.mapNotNull { it.destination.route },
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
NavHost(navController = navController, startDestination = Screen.Shell.route) {
|
|
20
33
|
composable(Screen.Shell.route) {
|
|
21
34
|
val tabs = appTabs(
|
|
@@ -1,44 +1,26 @@
|
|
|
1
1
|
package __PACKAGE__.presentation.navigation
|
|
2
2
|
|
|
3
|
-
import androidx.compose.foundation.background
|
|
4
|
-
import androidx.compose.foundation.clickable
|
|
5
|
-
import androidx.compose.foundation.layout.Arrangement
|
|
6
3
|
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
4
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
11
|
-
import androidx.compose.foundation.layout.fillMaxWidth
|
|
12
|
-
import androidx.compose.foundation.layout.height
|
|
13
|
-
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
14
|
-
import androidx.compose.foundation.layout.padding
|
|
15
|
-
import androidx.compose.foundation.layout.size
|
|
16
|
-
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
17
|
-
import androidx.compose.material3.Icon
|
|
18
|
-
import androidx.compose.material3.Text
|
|
19
5
|
import androidx.compose.runtime.Composable
|
|
20
6
|
import androidx.compose.runtime.getValue
|
|
21
7
|
import androidx.compose.runtime.mutableIntStateOf
|
|
22
8
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
23
9
|
import androidx.compose.runtime.setValue
|
|
24
|
-
import androidx.compose.ui.Alignment
|
|
25
10
|
import androidx.compose.ui.Modifier
|
|
26
|
-
import
|
|
27
|
-
import androidx.compose.ui.semantics.semantics
|
|
28
|
-
import androidx.compose.ui.semantics.testTag
|
|
29
|
-
import androidx.compose.ui.text.font.FontWeight
|
|
30
|
-
import androidx.compose.ui.unit.dp
|
|
31
|
-
import androidx.compose.ui.unit.sp
|
|
11
|
+
import __PACKAGE__.presentation.components.AppBottomBar
|
|
32
12
|
import __PACKAGE__.presentation.components.BaseScreen
|
|
33
13
|
import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
|
|
34
|
-
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
35
|
-
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
36
|
-
import __PACKAGE__.presentation.theme.designToken
|
|
37
14
|
|
|
38
15
|
/**
|
|
39
16
|
* Generic bottom-nav shell. Parameterized by a [tabs] list — NOT role-hardcoded.
|
|
40
17
|
* Hosts the selected tab's content inside a [BaseScreen] so each tab gets correct insets;
|
|
41
18
|
* the bottom bar reserves the navigation-bar inset exactly once.
|
|
19
|
+
*
|
|
20
|
+
* The bar itself is [__PACKAGE__.presentation.components.AppBottomBar] — promoted out of
|
|
21
|
+
* this file into the governed component registry (§4.3 of the component-vocabulary
|
|
22
|
+
* proposal): it was already a mature component, just invisible to the registry as a
|
|
23
|
+
* `private` composable here.
|
|
42
24
|
*/
|
|
43
25
|
@Composable
|
|
44
26
|
fun AppShell(tabs: List<AppTab>) {
|
|
@@ -52,7 +34,7 @@ fun AppShell(tabs: List<AppTab>) {
|
|
|
52
34
|
// The bottom bar owns the navigation-bar inset; the body must not also pad it.
|
|
53
35
|
applyNavBarPadding = false,
|
|
54
36
|
bottomBar = {
|
|
55
|
-
|
|
37
|
+
AppBottomBar(
|
|
56
38
|
tabs = tabs,
|
|
57
39
|
selectedIndex = selected,
|
|
58
40
|
onSelect = { selected = it },
|
|
@@ -64,87 +46,3 @@ fun AppShell(tabs: List<AppTab>) {
|
|
|
64
46
|
}
|
|
65
47
|
}
|
|
66
48
|
}
|
|
67
|
-
|
|
68
|
-
@Composable
|
|
69
|
-
private fun AppBottomNav(
|
|
70
|
-
tabs: List<AppTab>,
|
|
71
|
-
selectedIndex: Int,
|
|
72
|
-
onSelect: (Int) -> Unit,
|
|
73
|
-
) {
|
|
74
|
-
Column(Modifier.fillMaxWidth()) {
|
|
75
|
-
Box(
|
|
76
|
-
Modifier
|
|
77
|
-
.fillMaxWidth()
|
|
78
|
-
.height(1.dp)
|
|
79
|
-
.background(__THEME_PREFIX__Colors.OutlineVariant)
|
|
80
|
-
)
|
|
81
|
-
Row(
|
|
82
|
-
modifier = Modifier
|
|
83
|
-
.fillMaxWidth()
|
|
84
|
-
.background(__THEME_PREFIX__Colors.Surface)
|
|
85
|
-
// Lift tabs above the gesture pill / 3-button nav (maps to iOS safe area).
|
|
86
|
-
.navigationBarsPadding()
|
|
87
|
-
.height(__THEME_PREFIX__Tokens.BottomNavHeight)
|
|
88
|
-
// Inspector: the bottom-nav container self-reports its height token.
|
|
89
|
-
.designToken(
|
|
90
|
-
tokens = listOf("BottomNavHeight"),
|
|
91
|
-
resolved = mapOf("height" to "72dp"),
|
|
92
|
-
)
|
|
93
|
-
.semantics { testTag = "app_bottom_nav" }
|
|
94
|
-
.padding(bottom = 8.dp),
|
|
95
|
-
verticalAlignment = Alignment.CenterVertically,
|
|
96
|
-
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
97
|
-
) {
|
|
98
|
-
tabs.forEachIndexed { index, tab ->
|
|
99
|
-
NavItem(
|
|
100
|
-
label = tab.label,
|
|
101
|
-
selected = selectedIndex == index,
|
|
102
|
-
onClick = { onSelect(index) },
|
|
103
|
-
) {
|
|
104
|
-
Icon(
|
|
105
|
-
imageVector = tab.icon,
|
|
106
|
-
contentDescription = tab.label,
|
|
107
|
-
tint = if (selectedIndex == index) __THEME_PREFIX__Colors.Primary
|
|
108
|
-
else __THEME_PREFIX__Colors.OnSurfaceVariant.copy(alpha = 0.55f),
|
|
109
|
-
modifier = Modifier.size(24.dp),
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Deterministic automation tag for a nav item: `nav_` + the label lowercased with every non-[a-z0-9] run collapsed to `_` and trimmed (e.g. "My Stuff!" → `nav_my_stuff`). Must mirror `navSlug` in create-cmp's engine (src/lib/tabs.mjs), which generates `qa/e2e/smoke.yaml`'s id selectors from the configured tabs — keep the two in sync. */
|
|
118
|
-
private fun navItemTag(label: String): String =
|
|
119
|
-
"nav_" + label.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
|
|
120
|
-
|
|
121
|
-
@Composable
|
|
122
|
-
private fun NavItem(
|
|
123
|
-
label: String,
|
|
124
|
-
selected: Boolean,
|
|
125
|
-
onClick: () -> Unit,
|
|
126
|
-
icon: @Composable () -> Unit,
|
|
127
|
-
) {
|
|
128
|
-
Column(
|
|
129
|
-
modifier = Modifier
|
|
130
|
-
.clip(RoundedCornerShape(8.dp))
|
|
131
|
-
.clickable(onClick = onClick)
|
|
132
|
-
// a11y: guarantee the 48dp minimum touch target regardless of label width
|
|
133
|
-
// (the inspector's audit_a11y flags anything smaller).
|
|
134
|
-
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
|
135
|
-
// Durable selection handle (tests/E2E select by testTag, never display text).
|
|
136
|
-
.semantics { testTag = navItemTag(label) }
|
|
137
|
-
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
138
|
-
horizontalAlignment = Alignment.CenterHorizontally,
|
|
139
|
-
verticalArrangement = Arrangement.Center,
|
|
140
|
-
) {
|
|
141
|
-
icon()
|
|
142
|
-
Text(
|
|
143
|
-
text = label,
|
|
144
|
-
fontSize = 10.sp,
|
|
145
|
-
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
|
|
146
|
-
color = if (selected) __THEME_PREFIX__Colors.Primary else __THEME_PREFIX__Colors.OnSurfaceVariant,
|
|
147
|
-
modifier = Modifier.padding(top = 4.dp),
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package __PACKAGE__.presentation.navigation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A tiny common seam [AppNavHost] reports every navigation change to. [listener] is `null` by
|
|
5
|
+
* default — a structural no-op — and is registered only by the optional debug-only on-device
|
|
6
|
+
* inspector, when that feature is included in the project. Release builds never register a
|
|
7
|
+
* listener, so invoking a `null` one stays free.
|
|
8
|
+
*
|
|
9
|
+
* This indirection is the whole point: `AppNavHost` lives in commonMain and must never
|
|
10
|
+
* reference a debug-only class directly (a debug-only source set may not even exist in every
|
|
11
|
+
* build of this project) — it only ever talks to this common object.
|
|
12
|
+
*/
|
|
13
|
+
object NavInspectionHook {
|
|
14
|
+
/**
|
|
15
|
+
* Invoked on every back-stack change with `currentRoute` (nullable — no destination
|
|
16
|
+
* resolved yet) and `backStack` (root-to-top, best-effort: derived from
|
|
17
|
+
* `NavController.currentBackStack`, which is itself a live/soon-to-be-live snapshot, not a
|
|
18
|
+
* durable history).
|
|
19
|
+
*/
|
|
20
|
+
var listener: ((currentRoute: String?, backStack: List<String>) -> Unit)? = null
|
|
21
|
+
}
|
|
@@ -1,39 +1,16 @@
|
|
|
1
1
|
package __PACKAGE__.presentation.profile
|
|
2
2
|
|
|
3
|
-
import androidx.compose.foundation.layout.Arrangement
|
|
4
|
-
import androidx.compose.foundation.layout.Column
|
|
5
|
-
import androidx.compose.foundation.layout.fillMaxSize
|
|
6
|
-
import androidx.compose.foundation.layout.padding
|
|
7
3
|
import androidx.compose.material3.MaterialTheme
|
|
8
4
|
import androidx.compose.material3.Text
|
|
9
5
|
import androidx.compose.runtime.Composable
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import androidx.compose.ui.semantics.semantics
|
|
13
|
-
import androidx.compose.ui.semantics.testTag
|
|
14
|
-
import androidx.compose.ui.unit.dp
|
|
15
|
-
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
16
|
-
import __PACKAGE__.presentation.theme.designToken
|
|
6
|
+
import __PACKAGE__.presentation.components.AppHeader
|
|
7
|
+
import __PACKAGE__.presentation.components.ScreenColumn
|
|
17
8
|
|
|
18
9
|
// Feature stub. Copy the `home` feature's data→domain→presentation→DI wiring to flesh this out.
|
|
19
10
|
@Composable
|
|
20
11
|
fun ProfileScreen() {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.fillMaxSize()
|
|
24
|
-
.designToken(
|
|
25
|
-
tokens = listOf("PaddingPage"),
|
|
26
|
-
resolved = mapOf("padding" to "16dp"),
|
|
27
|
-
)
|
|
28
|
-
.padding(__THEME_PREFIX__Tokens.PaddingPage),
|
|
29
|
-
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
30
|
-
horizontalAlignment = Alignment.Start,
|
|
31
|
-
) {
|
|
32
|
-
Text(
|
|
33
|
-
text = "Profile",
|
|
34
|
-
style = MaterialTheme.typography.headlineMedium,
|
|
35
|
-
modifier = Modifier.semantics { testTag = "profile_title" },
|
|
36
|
-
)
|
|
12
|
+
ScreenColumn(screenTag = "profile") {
|
|
13
|
+
AppHeader(title = "Profile", screenTag = "profile")
|
|
37
14
|
Text(
|
|
38
15
|
text = "This is a stub screen. Wire it up like the Home feature.",
|
|
39
16
|
style = MaterialTheme.typography.bodyMedium,
|
package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package __PACKAGE__.data
|
|
2
|
+
|
|
3
|
+
import __PACKAGE__.domain.model.DomainError
|
|
4
|
+
import __PACKAGE__.domain.result.AppResult
|
|
5
|
+
import kotlin.coroutines.cancellation.CancellationException
|
|
6
|
+
import kotlin.test.Test
|
|
7
|
+
import kotlin.test.assertEquals
|
|
8
|
+
import kotlin.test.assertFailsWith
|
|
9
|
+
import kotlin.test.assertIs
|
|
10
|
+
import kotlinx.coroutines.test.runTest
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The exception-translation helper's contract (`specs/app-base.spec.md` ARCH-08):
|
|
14
|
+
* success wraps, failures map to typed kinds, and cancellation ALWAYS propagates.
|
|
15
|
+
*/
|
|
16
|
+
class AppResultCatchingTest {
|
|
17
|
+
|
|
18
|
+
@Test
|
|
19
|
+
fun `wraps the block's value in Success`() = runTest {
|
|
20
|
+
val result = suspendRunCatching { 42 }
|
|
21
|
+
|
|
22
|
+
assertEquals(AppResult.Success(42), result)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
fun `maps a thrown exception through mapError to a typed Failure`() = runTest {
|
|
27
|
+
val boom = IllegalStateException("io broke")
|
|
28
|
+
|
|
29
|
+
val result = suspendRunCatching(mapError = { DomainError.Network }) { throw boom }
|
|
30
|
+
|
|
31
|
+
assertEquals(AppResult.Failure(DomainError.Network), result)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test
|
|
35
|
+
fun `files unclassified exceptions under Unexpected with the cause preserved`() = runTest {
|
|
36
|
+
val boom = IllegalStateException("io broke")
|
|
37
|
+
|
|
38
|
+
val result = suspendRunCatching { throw boom }
|
|
39
|
+
|
|
40
|
+
val failure = assertIs<AppResult.Failure>(result)
|
|
41
|
+
val error = assertIs<DomainError.Unexpected>(failure.error)
|
|
42
|
+
assertEquals(boom, error.cause)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// SPEC: ARCH-08
|
|
46
|
+
@Test
|
|
47
|
+
fun `rethrows CancellationException instead of mapping it`() = runTest {
|
|
48
|
+
assertFailsWith<CancellationException> {
|
|
49
|
+
suspendRunCatching { throw CancellationException("scope cancelled") }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|