feat(core:design-system): AppTheme + reusable composables (REDI-81)

- AppTheme wraps Material3 (color scheme, typography, shapes); all previews use it.
- Slot-API AppCard (header + content slots, optional click); AppScaffold.
- LoadingIndicator, ErrorState (optional retry), Coil-backed NetworkImage.
- Modifier.shimmerEffect() animated placeholder (Modifier extension, not @Composable).
- Add androidx-compose-foundation to the version catalog + compose bundle.
This commit is contained in:
2026-06-10 11:37:31 +02:00
parent 6bc4027cbb
commit 3a155beb3c
14 changed files with 338 additions and 0 deletions

View File

@@ -26,6 +26,9 @@ class ComposeConventionPlugin : Plugin<Project> {
}
dependencies {
// `implementation` (not api): every Compose consumer applies this convention itself, so
// Compose must NOT leak transitively — that keeps the UI-agnostic presentation module
// (which depends on core:presentation) free of Compose.
val bom = platform(libs.findLibrary("androidx-compose-bom").get())
add("implementation", bom)
add("androidTestImplementation", bom)

View File

@@ -6,3 +6,9 @@ plugins {
android {
namespace = "com.example.architecture.core.design.system"
}
dependencies {
// Coil is internal to NetworkImage; no Coil types leak into public signatures.
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
}

View File

@@ -0,0 +1,58 @@
package com.example.architecture.core.design.system.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.architecture.core.design.system.theme.AppTheme
/**
* Slot-API card. Callers compose into an optional [header] slot and the [content] slot
* (a `ColumnScope`), and may make the whole card clickable. Feature code decides what goes inside.
*/
@Composable
fun AppCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
header: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val body: @Composable ColumnScope.() -> Unit = {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
header?.invoke()
content()
}
}
if (onClick != null) {
Card(onClick = onClick, modifier = modifier, content = body)
} else {
Card(modifier = modifier, content = body)
}
}
@Preview
@Composable
private fun AppCardPreview() {
AppTheme {
AppCard(
modifier = Modifier.padding(16.dp),
onClick = {},
header = { Text("Rick Sanchez", style = MaterialTheme.typography.titleMedium) },
) {
Text(
text = "Human · Alive · Earth (C-137)",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}

View File

@@ -0,0 +1,23 @@
package com.example.architecture.core.design.system.component
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Thin wrapper over [Scaffold] giving screens a consistent surface. Slot API: callers provide the
* [topBar] and the [content] (which receives the inner [PaddingValues] to consume).
*/
@Composable
fun AppScaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = topBar,
content = content,
)
}

View File

@@ -0,0 +1,56 @@
package com.example.architecture.core.design.system.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.architecture.core.design.system.R
import com.example.architecture.core.design.system.theme.AppTheme
/**
* Centered error message with an optional retry button. The message is already-resolved text
* (the caller maps its error/`UiText` to a String); the retry label is localized here.
*/
@Composable
fun ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
if (onRetry != null) {
Button(onClick = onRetry) {
Text(text = stringResource(R.string.designsystem_retry))
}
}
}
}
@Preview
@Composable
private fun ErrorStatePreview() {
AppTheme {
ErrorState(message = "No internet connection.", onRetry = {})
}
}

View File

@@ -0,0 +1,23 @@
package com.example.architecture.core.design.system.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.architecture.core.design.system.theme.AppTheme
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Preview
@Composable
private fun LoadingIndicatorPreview() {
AppTheme { LoadingIndicator() }
}

View File

@@ -0,0 +1,31 @@
package com.example.architecture.core.design.system.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
/**
* Coil-backed remote image. Coil 3 auto-registers the OkHttp network fetcher from
* `coil-network-okhttp` on the classpath, so callers just pass a URL.
*/
@Composable
fun NetworkImage(
imageUrl: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
modifier = modifier,
)
}

View File

@@ -0,0 +1,49 @@
package com.example.architecture.core.design.system.modifier
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize
/**
* Animated placeholder shimmer for loading skeletons. Implemented as a `Modifier` extension (not a
* `@Composable`); `composed` lets it read the theme and animate while the gradient is repainted
* below the recomposition layer via [background].
*/
fun Modifier.shimmerEffect(): Modifier = composed {
var size by remember { mutableStateOf(IntSize.Zero) }
val transition = rememberInfiniteTransition(label = "shimmer")
val startOffsetX by transition.animateFloat(
initialValue = -2f * size.width,
targetValue = 2f * size.width,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Restart,
),
label = "shimmerOffsetX",
)
val base = MaterialTheme.colorScheme.surfaceVariant
val highlight = MaterialTheme.colorScheme.surface
background(
brush = Brush.linearGradient(
colors = listOf(base, highlight, base),
start = Offset(startOffsetX, 0f),
end = Offset(startOffsetX + size.width, size.height.toFloat()),
),
).onGloballyPositioned { size = it.size }
}

View File

@@ -0,0 +1,45 @@
package com.example.architecture.core.design.system.theme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
// Brand palette — seeded from the Android green used by the project.
private val Green10 = Color(0xFF00210B)
private val Green20 = Color(0xFF003918)
private val Green40 = Color(0xFF1E6C36)
private val Green80 = Color(0xFF8FD89B)
private val Green90 = Color(0xFFAAF5B5)
private val Teal40 = Color(0xFF36687A)
private val Teal80 = Color(0xFF9ECEE3)
private val Neutral10 = Color(0xFF191C1A)
private val Neutral90 = Color(0xFFE1E3DE)
private val Neutral99 = Color(0xFFFBFDF7)
internal val LightColorScheme = lightColorScheme(
primary = Green40,
onPrimary = Color.White,
primaryContainer = Green90,
onPrimaryContainer = Green10,
secondary = Teal40,
onSecondary = Color.White,
background = Neutral99,
onBackground = Neutral10,
surface = Neutral99,
onSurface = Neutral10,
)
internal val DarkColorScheme = darkColorScheme(
primary = Green80,
onPrimary = Green20,
primaryContainer = Green40,
onPrimaryContainer = Green90,
secondary = Teal80,
onSecondary = Neutral10,
background = Neutral10,
onBackground = Neutral90,
surface = Neutral10,
onSurface = Neutral90,
)

View File

@@ -0,0 +1,11 @@
package com.example.architecture.core.design.system.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
internal val AppShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
)

View File

@@ -0,0 +1,22 @@
package com.example.architecture.core.design.system.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
/**
* The single Compose theme for the app. Every screen and every `@Preview` is wrapped in this so
* they reflect real appearance. Dynamic color is intentionally off to keep the brand identity.
*/
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content,
)
}

View File

@@ -0,0 +1,6 @@
package com.example.architecture.core.design.system.theme
import androidx.compose.material3.Typography
// Material3 baseline type scale. Swap in custom font families here if the brand needs them.
internal val AppTypography = Typography()

View File

@@ -0,0 +1,3 @@
<resources>
<string name="designsystem_retry">Retry</string>
</resources>

View File

@@ -74,6 +74,7 @@ material = { module = "com.google.android.material:material", version.ref = "mat
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
@@ -127,6 +128,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
compose = [
"androidx-compose-ui",
"androidx-compose-ui-graphics",
"androidx-compose-foundation",
"androidx-compose-ui-tooling-preview",
"androidx-compose-material3",
]