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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
3
core/design-system/src/main/res/values/strings.xml
Normal file
3
core/design-system/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="designsystem_retry">Retry</string>
|
||||
</resources>
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user