diff --git a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt index 44c7274..79571df 100644 --- a/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/com/example/architecture/convention/ComposeConventionPlugin.kt @@ -26,6 +26,9 @@ class ComposeConventionPlugin : Plugin { } 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) diff --git a/core/design-system/build.gradle.kts b/core/design-system/build.gradle.kts index 08c4377..91d3610 100644 --- a/core/design-system/build.gradle.kts +++ b/core/design-system/build.gradle.kts @@ -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) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppCard.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppCard.kt new file mode 100644 index 0000000..531a908 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppCard.kt @@ -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, + ) + } + } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppScaffold.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppScaffold.kt new file mode 100644 index 0000000..57c958f --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/AppScaffold.kt @@ -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, + ) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/ErrorState.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/ErrorState.kt new file mode 100644 index 0000000..12fa43e --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/ErrorState.kt @@ -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 = {}) + } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/LoadingIndicator.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/LoadingIndicator.kt new file mode 100644 index 0000000..a5f7ee5 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/LoadingIndicator.kt @@ -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() } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/NetworkImage.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/NetworkImage.kt new file mode 100644 index 0000000..534f1e1 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/component/NetworkImage.kt @@ -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, + ) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/modifier/ShimmerEffect.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/modifier/ShimmerEffect.kt new file mode 100644 index 0000000..8fd35e2 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/modifier/ShimmerEffect.kt @@ -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 } +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Color.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Color.kt new file mode 100644 index 0000000..505b7d1 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Color.kt @@ -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, +) diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Shape.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Shape.kt new file mode 100644 index 0000000..db7f6e3 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Shape.kt @@ -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), +) diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Theme.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Theme.kt new file mode 100644 index 0000000..97b07f8 --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Theme.kt @@ -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, + ) +} diff --git a/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Type.kt b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Type.kt new file mode 100644 index 0000000..116020d --- /dev/null +++ b/core/design-system/src/main/kotlin/com/example/architecture/core/design/system/theme/Type.kt @@ -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() diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml new file mode 100644 index 0000000..11f738c --- /dev/null +++ b/core/design-system/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Retry + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d75c6e9..5ee5428 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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", ]