Imported from ronjunevaldoz/graphyn-editor (
.agents/skills/kotlin-multiplatform-design-system-extended/SKILL.md). Install upstream withnpx skills add ronjunevaldoz/graphyn-editor --skill kotlin-multiplatform-design-system-extended. Copyright stays with the author (Apache-2.0).
When to Use This Skill
Use after kotlin-multiplatform-design-system has been applied. Use when the user asks to:
- Add Dialog, BottomSheet, Toast/Snackbar, Tabs, TopAppBar, or BottomNav
- Add form controls: Checkbox, RadioButton, Switch, Slider, Select/Dropdown
- Add loading states: Progress, Skeleton, Spinner
- Add Tooltip, Popover, Accordion/Collapsible, Avatar, Separator
- Complete the design system for production use
Trigger keywords: dialog, bottom sheet, toast, snackbar, tabs, top app bar, bottom navigation, checkbox, radio, switch, slider, select, dropdown, progress bar, loading, skeleton, spinner, tooltip, popover, accordion, collapsible, avatar, divider, separator, icon button, form label, extended design system, redesign, visual consistency, UI components, component library, page components, add components, component set, UI kit, component design, redesign page, button, dialog, component, use component, add button, create component, show dialog, show toast, loading state, empty state, error state.
Freshness rule: @ExperimentalStylesApi and CMP primitive APIs change between releases —
recheck the Compose docs and apply the same freshness check as kotlin-multiplatform-design-system.
Recommendation First
Default to using a pre-built extended component before building a custom one.
Why:
- all 27 components are built on CMP primitives with no Material dependency — they are safe to use alongside the base design system
- they follow the same sealed variant pattern as the core components, so the token layer stays consistent
- building a custom component takes longer and may drift from the design system tokens
Only build a custom component when none of the 27 extended components fit the design requirement,
and apply the same @ExperimentalStylesApi token pattern as the core system.
Prerequisites
kotlin-multiplatform-design-systemskill already applied (tokens, AppTheme, StyleScopeExtensions, 6 core components present):core:designsystemmodule exists withGROUP_ID.core.designsystempackage
Ownership Model
Skill-owned. All extended components are updateable via
/update-design-system. Project-owned files (tokens/,theme/) are never touched.
Component Overview
| Group | Components | Stability |
|---|---|---|
| Primitives | AppIcon, AppIconButton, AppLabel, AppSeparator |
Stable |
| Display | AppAvatar, AppSpinner, AppSkeleton, AppProgress |
Stable |
| Navigation | AppTopAppBar, AppNavigationBar, AppScaffold |
Stable |
| Tabs | AppTabs |
Stable |
| Form Controls | AppCheckbox, AppRadioButton, AppSwitch, AppSlider |
Stable |
| Form Controls | AppSelect |
Experimental — API may change |
| Feedback | AppAlert, AppToast (with ToastHostState + AppScaffold) |
Stable |
| Overlays | AppDialog, AppAlertDialog, AppSheet |
Stable |
| Overlays | AppTooltip, AppPopover |
Experimental — positioning varies by platform |
| Expandable | AppAccordion |
Experimental — animation API in flux |
Stability tiers:
- Stable — API locked; breaking changes come with a migration note in the Changelog.
- Experimental — API may change between skill versions; review diffs before accepting updates.
Step 1: Add styles for new components
styles/CheckboxStyles.kt
package GROUP_ID.core.designsystem.styles
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.style.Style
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.colors
import GROUP_ID.core.designsystem.theme.shapes
sealed interface CheckboxVariant {
val checkedStyle: Style
val uncheckedStyle: Style
data object Default : CheckboxVariant {
override val checkedStyle = Style {
background(colors.primary)
borderWidth(0.dp)
shape(RoundedCornerShape(shapes.sm))
contentColor(colors.onPrimary)
}
override val uncheckedStyle = Style {
borderWidth(1.dp)
borderColor(colors.border)
shape(RoundedCornerShape(shapes.sm))
hovered { animate { borderColor(colors.onSurface) } }
}
}
data object Destructive : CheckboxVariant {
override val checkedStyle = Style {
background(colors.destructive)
borderWidth(0.dp)
shape(RoundedCornerShape(shapes.sm))
contentColor(colors.onDestructive)
}
override val uncheckedStyle = Default.uncheckedStyle
}
}
styles/SwitchStyles.kt
package GROUP_ID.core.designsystem.styles
import androidx.compose.foundation.style.Style
import GROUP_ID.core.designsystem.theme.colors
import GROUP_ID.core.designsystem.theme.shapes
data class SwitchColors(
val trackChecked: androidx.compose.ui.graphics.Color,
val trackUnchecked: androidx.compose.ui.graphics.Color,
val thumb: androidx.compose.ui.graphics.Color,
)
object SwitchDefaults {
@Composable
fun colors() = SwitchColors(
trackChecked = AppTheme.LocalAppTheme.current.colors.primary,
trackUnchecked = AppTheme.LocalAppTheme.current.colors.border,
thumb = AppTheme.LocalAppTheme.current.colors.background,
)
}
styles/TabStyles.kt
package GROUP_ID.core.designsystem.styles
import androidx.compose.foundation.style.Style
import GROUP_ID.core.designsystem.theme.colors
import GROUP_ID.core.designsystem.theme.spacing
data class TabColors(
val indicator: androidx.compose.ui.graphics.Color,
val selected: androidx.compose.ui.graphics.Color,
val unselected: androidx.compose.ui.graphics.Color,
val background: androidx.compose.ui.graphics.Color,
)
object TabDefaults {
@Composable
fun colors() = TabColors(
indicator = AppTheme.LocalAppTheme.current.colors.primary,
selected = AppTheme.LocalAppTheme.current.colors.onSurface,
unselected = AppTheme.LocalAppTheme.current.colors.onSurfaceVariant,
background = AppTheme.LocalAppTheme.current.colors.background,
)
}
sealed interface TabVariant {
data object Line : TabVariant // underline indicator (default)
data object Pill : TabVariant // filled pill indicator
data object Enclosed : TabVariant // enclosed in a container
}
styles/AlertStyles.kt
package GROUP_ID.core.designsystem.styles
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.style.Style
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.colors
import GROUP_ID.core.designsystem.theme.shapes
import GROUP_ID.core.designsystem.theme.spacing
sealed interface AlertVariant {
val style: Style
data object Default : AlertVariant {
override val style = Style {
background(colors.surface)
contentColor(colors.onSurface)
borderWidth(1.dp)
borderColor(colors.border)
shape(RoundedCornerShape(shapes.lg))
padding(all = spacing.lg)
}
}
data object Destructive : AlertVariant {
override val style = Style {
background(colors.destructive)
contentColor(colors.onDestructive)
borderWidth(0.dp)
shape(RoundedCornerShape(shapes.lg))
padding(all = spacing.lg)
}
}
data object Warning : AlertVariant {
override val style = Style {
background(colors.warning)
contentColor(colors.onStatus)
borderWidth(0.dp)
shape(RoundedCornerShape(shapes.lg))
padding(all = spacing.lg)
}
}
data object Success : AlertVariant {
override val style = Style {
background(colors.success)
contentColor(colors.onStatus)
borderWidth(0.dp)
shape(RoundedCornerShape(shapes.lg))
padding(all = spacing.lg)
}
}
}
Step 2: Primitive components
components/AppIcon.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
sealed interface IconSize {
val dp: Dp
data object Xs : IconSize { override val dp = 12.dp }
data object Sm : IconSize { override val dp = 16.dp }
data object Md : IconSize { override val dp = 20.dp }
data object Lg : IconSize { override val dp = 24.dp }
data object Xl : IconSize { override val dp = 32.dp }
}
/**
* Usage:
* ```
* AppIcon(Icons.Default.Search)
* AppIcon(Icons.Default.Close, size = IconSize.Sm, tint = colors.destructive)
* ```
*/
@Composable
fun AppIcon(
imageVector: ImageVector,
contentDescription: String? = null,
modifier: Modifier = Modifier,
size: IconSize = IconSize.Md,
tint: Color = Color.Unspecified,
) {
val resolvedTint = if (tint == Color.Unspecified) appTheme.colors.onSurface else tint
androidx.compose.foundation.Image(
painter = rememberVectorPainter(imageVector),
contentDescription = contentDescription,
modifier = modifier.size(size.dp),
colorFilter = ColorFilter.tint(resolvedTint),
)
}
components/AppIconButton.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.style.MutableStyleState
import androidx.compose.foundation.style.Style
import androidx.compose.foundation.style.styleable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.styles.ButtonVariant
/**
* Icon-only button. Defaults to Ghost variant.
*
* Usage:
* ```
* AppIconButton(onClick = { navBack() }) {
* AppIcon(Icons.Default.ArrowBack, contentDescription = "Back")
* }
* AppIconButton(onClick = { delete() }, variant = ButtonVariant.Destructive) {
* AppIcon(Icons.Default.Delete, contentDescription = "Delete")
* }
* ```
*/
@Composable
fun AppIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
variant: ButtonVariant = ButtonVariant.Ghost,
style: Style = Style,
content: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
styleState.enabled = enabled
Box(
modifier = modifier
.size(40.dp)
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
role = Role.Button,
onClick = onClick,
)
.styleable(styleState, variant.style, style),
contentAlignment = Alignment.Center,
) {
content()
}
}
components/AppLabel.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Form field label with optional required indicator.
*
* Usage:
* ```
* AppLabel(text = "Email address", required = true)
* AppLabel(text = "Bio")
* ```
*/
@Composable
fun AppLabel(
text: String,
modifier: Modifier = Modifier,
required: Boolean = false,
enabled: Boolean = true,
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
AppText(
text = text,
style = AppTextStyle.LabelLarge,
muted = !enabled,
)
if (required) {
Spacer(Modifier.width(2.dp))
AppText(
text = "*",
style = AppTextStyle.LabelLarge,
color = AppTheme.LocalAppTheme.current.colors.destructive,
)
}
}
}
components/AppSeparator.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Usage:
* ```
* AppSeparator() // horizontal, full width
* AppSeparator(vertical = true) // vertical, full height
* AppSeparator(thickness = 2.dp)
* ```
*/
@Composable
fun AppSeparator(
modifier: Modifier = Modifier,
vertical: Boolean = false,
thickness: Dp = 1.dp,
color: Color = appTheme.colors.border,
) {
Box(
modifier = modifier
.clearAndSetSemantics {}
.then(
if (vertical) Modifier.width(thickness).fillMaxHeight()
else Modifier.height(thickness).fillMaxWidth()
)
.background(color),
)
}
components/AppAvatar.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.style.MutableStyleState
import androidx.compose.foundation.style.Style
import androidx.compose.foundation.style.styleable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
sealed interface AvatarSize {
val dp: Dp
data object Sm : AvatarSize { override val dp = 32.dp }
data object Md : AvatarSize { override val dp = 40.dp }
data object Lg : AvatarSize { override val dp = 56.dp }
data object Xl : AvatarSize { override val dp = 72.dp }
}
/**
* Usage:
* ```
* AppAvatar(initials = "RV")
* AppAvatar(initials = "RV", size = AvatarSize.Lg)
* AppAvatar(painter = painterResource(Res.drawable.ic_user), contentDescription = "Profile")
* ```
*/
@Composable
fun AppAvatar(
modifier: Modifier = Modifier,
initials: String? = null,
painter: Painter? = null,
contentDescription: String? = null,
size: AvatarSize = AvatarSize.Md,
) {
val theme = appTheme
Box(
modifier = modifier
.size(size.dp)
.clip(CircleShape)
.background(theme.colors.secondary),
contentAlignment = Alignment.Center,
) {
if (painter != null) {
androidx.compose.foundation.Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier.size(size.dp),
)
} else if (initials != null) {
AppText(
text = initials.take(2).uppercase(),
style = if (size.dp >= 56.dp) AppTextStyle.TitleSmall else AppTextStyle.LabelLarge,
color = theme.colors.onSecondary,
)
}
}
}
Step 3: Loading state components
components/AppSpinner.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.animation.core.LinearEasing
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.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
sealed interface SpinnerSize {
val dp: Dp
val stroke: Float
data object Sm : SpinnerSize { override val dp = 16.dp; override val stroke = 2f }
data object Md : SpinnerSize { override val dp = 24.dp; override val stroke = 2.5f }
data object Lg : SpinnerSize { override val dp = 32.dp; override val stroke = 3f }
}
/**
* Circular indeterminate spinner. Uses rememberInfiniteTransition (Styles API
* doesn't support infinite animations).
*
* Usage:
* ```
* AppSpinner()
* AppSpinner(size = SpinnerSize.Sm, color = colors.onPrimary)
* ```
*/
@Composable
fun AppSpinner(
modifier: Modifier = Modifier,
size: SpinnerSize = SpinnerSize.Md,
color: Color = appTheme.colors.primary,
) {
val infiniteTransition = rememberInfiniteTransition(label = "spinner")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "spinnerRotation",
)
Canvas(modifier = modifier.size(size.dp)) {
val padding = size.stroke / 2
drawArc(
color = color.copy(alpha = 0.2f),
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = size.stroke, cap = StrokeCap.Round),
)
drawArc(
color = color,
startAngle = rotation,
sweepAngle = 270f,
useCenter = false,
style = Stroke(width = size.stroke, cap = StrokeCap.Round),
)
}
}
components/AppProgress.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
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.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Linear progress bar. Pass null for indeterminate.
*
* Usage:
* ```
* AppProgress(progress = 0.75f) // 75% filled
* AppProgress(progress = null) // indeterminate — uses AppSpinner internally
* AppProgress(progress = 0.5f, height = 8.dp)
* ```
*/
@Composable
fun AppProgress(
progress: Float?,
modifier: Modifier = Modifier,
height: Dp = 4.dp,
color: Color = appTheme.colors.primary,
trackColor: Color = appTheme.colors.secondary,
) {
if (progress == null) {
// Indeterminate — animate a sweeping bar
val infiniteTransition = rememberInfiniteTransition(label = "progress")
val offsetFraction by infiniteTransition.animateFloat(
initialValue = -0.5f,
targetValue = 1.5f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Restart,
),
label = "progressOffset",
)
// Capture the track's pixel width so graphicsLayer can translate across the full track,
// not just across the inner indicator box (which is only 40% wide).
var containerWidth by remember { mutableStateOf(0) }
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(CircleShape)
.background(trackColor)
.onSizeChanged { containerWidth = it.width },
) {
Box(
modifier = Modifier
.fillMaxWidth(0.4f)
.height(height)
.clip(CircleShape)
.background(color)
.graphicsLayer { translationX = containerWidth * offsetFraction },
)
}
} else {
val animatedProgress by animateFloatAsState(
targetValue = progress.coerceIn(0f, 1f),
animationSpec = tween(durationMillis = 300),
label = "progressValue",
)
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(CircleShape)
.background(trackColor),
) {
Box(
modifier = Modifier
.fillMaxWidth(animatedProgress)
.height(height)
.clip(CircleShape)
.background(color),
)
}
}
}
components/AppSkeleton.kt
package GROUP_ID.core.designsystem.components
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.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Pulsing skeleton placeholder for loading states.
*
* Usage:
* ```
* AppSkeleton(Modifier.fillMaxWidth().height(20.dp))
* AppSkeleton(Modifier.size(40.dp).clip(CircleShape)) // avatar skeleton
* ```
*/
@Composable
fun AppSkeleton(
modifier: Modifier = Modifier,
baseColor: Color = appTheme.colors.secondary,
highlightColor: Color = appTheme.colors.surfaceVariant,
) {
val infiniteTransition = rememberInfiniteTransition(label = "skeleton")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800),
repeatMode = RepeatMode.Reverse,
),
label = "skeletonAlpha",
)
Box(
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(baseColor.copy(alpha = alpha)),
)
}
Step 4: Navigation components
components/AppTopAppBar.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Usage:
* ```
* AppTopAppBar(
* title = "Settings",
* navigationIcon = {
* AppIconButton(onClick = { navBack() }) {
* AppIcon(Icons.Default.ArrowBack, contentDescription = "Back")
* }
* },
* actions = {
* AppIconButton(onClick = { openMenu() }) {
* AppIcon(Icons.Default.MoreVert, contentDescription = "More")
* }
* }
* )
* ```
*/
@Composable
fun AppTopAppBar(
title: String,
modifier: Modifier = Modifier,
navigationIcon: (@Composable () -> Unit)? = null,
actions: (@Composable () -> Unit)? = null,
backgroundColor: Color = appTheme.colors.background,
contentColor: Color = appTheme.colors.onSurface,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(backgroundColor)
.statusBarsPadding()
.height(56.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (navigationIcon != null) {
navigationIcon()
} else {
Spacer(Modifier.padding(start = 12.dp))
}
AppText(
text = title,
style = AppTextStyle.TitleSmall,
color = contentColor,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
)
if (actions != null) {
actions()
}
}
}
components/AppNavigationBar.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.style.MutableStyleState
import androidx.compose.foundation.style.Style
import androidx.compose.foundation.style.styleable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
data class NavBarItem(
val label: String,
val icon: ImageVector,
val selectedIcon: ImageVector = icon,
val contentDescription: String? = null,
)
/**
* Usage:
* ```
* val items = listOf(
* NavBarItem("Home", Icons.Outlined.Home, Icons.Filled.Home),
* NavBarItem("Search", Icons.Outlined.Search),
* NavBarItem("Profile", Icons.Outlined.Person, Icons.Filled.Person),
* )
* AppNavigationBar(items = items, selectedIndex = currentTab, onItemSelected = { tab = it })
* ```
*/
@Composable
fun AppNavigationBar(
items: List<NavBarItem>,
selectedIndex: Int,
onItemSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color = appTheme.colors.background,
) {
val theme = appTheme
Column(modifier = modifier.fillMaxWidth().background(backgroundColor)) {
AppSeparator()
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(64.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, item ->
val selected = index == selectedIndex
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier = Modifier
.weight(1f)
.height(64.dp)
.clickable(
interactionSource = interactionSource,
indication = null,
role = Role.Tab,
onClick = { onItemSelected(index) },
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
AppIcon(
imageVector = if (selected) item.selectedIcon else item.icon,
contentDescription = item.contentDescription ?: item.label,
size = IconSize.Md,
tint = if (selected) theme.colors.primary else theme.colors.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
AppText(
text = item.label,
style = AppTextStyle.LabelSmall,
color = if (selected) theme.colors.primary else theme.colors.onSurfaceVariant,
)
}
}
}
}
}
components/AppTabs.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.styles.TabDefaults
import GROUP_ID.core.designsystem.styles.TabVariant
import GROUP_ID.core.designsystem.theme.appTheme
import GROUP_ID.core.designsystem.theme.shapes
/**
* Usage:
* ```
* val tabs = listOf("Overview", "Activity", "Settings")
* AppTabs(
* tabs = tabs,
* selectedIndex = selectedTab,
* onTabSelected = { selectedTab = it },
* variant = TabVariant.Line,
* ) { index ->
* when (index) {
* 0 -> OverviewContent()
* 1 -> ActivityContent()
* else -> SettingsContent()
* }
* }
* ```
*/
@Composable
fun AppTabs(
tabs: List<String>,
selectedIndex: Int,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
variant: TabVariant = TabVariant.Line,
content: (@Composable (selectedIndex: Int) -> Unit)? = null,
) {
val theme = appTheme
val colors = TabDefaults.colors()
Column(modifier = modifier) {
// Tab row
when (variant) {
TabVariant.Line -> {
Box(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
tabs.forEachIndexed { index, title ->
val selected = index == selectedIndex
Column(
modifier = Modifier
.weight(1f)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onTabSelected(index) },
role = Role.Tab,
)
.padding(bottom = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppText(
text = title,
style = AppTextStyle.LabelLarge,
color = if (selected) colors.selected else colors.unselected,
modifier = Modifier.padding(vertical = 10.dp),
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.background(
if (selected) colors.indicator else androidx.compose.ui.graphics.Color.Transparent
),
)
}
}
}
AppSeparator(modifier = Modifier.align(Alignment.BottomCenter))
}
}
TabVariant.Pill -> {
Row(
modifier = Modifier
.fillMaxWidth()
.background(theme.colors.secondary, RoundedCornerShape(theme.shapes.full))
.padding(4.dp),
) {
tabs.forEachIndexed { index, title ->
val selected = index == selectedIndex
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(theme.shapes.full))
.background(if (selected) theme.colors.background else androidx.compose.ui.graphics.Color.Transparent)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onTabSelected(index) },
role = Role.Tab,
)
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
AppText(
text = title,
style = AppTextStyle.LabelLarge,
color = if (selected) colors.selected else colors.unselected,
)
}
}
}
}
TabVariant.Enclosed -> {
Row(
modifier = Modifier
.fillMaxWidth()
.background(theme.colors.surfaceVariant)
.padding(horizontal = 16.dp),
) {
tabs.forEachIndexed { index, title ->
val selected = index == selectedIndex
Column(
modifier = Modifier
.wrapContentWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onTabSelected(index) },
role = Role.Tab,
)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppText(
text = title,
style = AppTextStyle.LabelLarge,
color = if (selected) colors.selected else colors.unselected,
)
}
}
}
}
}
// Content area with crossfade
if (content != null) {
AnimatedContent(
targetState = selectedIndex,
transitionSpec = { fadeIn(tween(150)) togetherWith fadeOut(tween(150)) },
label = "tabContent",
) { index ->
content(index)
}
}
}
}
Step 5: Form controls
components/AppCheckbox.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Usage:
* ```
* AppCheckbox(checked = isChecked, onCheckedChange = { isChecked = it })
* AppCheckbox(checked = isChecked, onCheckedChange = { isChecked = it }, label = "Remember me")
* ```
*/
@Composable
fun AppCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: String? = null,
) {
val theme = appTheme
val interactionSource = remember { MutableInteractionSource() }
val checkAlpha by animateFloatAsState(
targetValue = if (checked) 1f else 0f,
animationSpec = tween(150),
label = "checkAlpha",
)
val rowModifier = if (label != null) {
modifier.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
role = Role.Checkbox,
onClick = { onCheckedChange(!checked) },
)
} else modifier
Row(
modifier = rowModifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Canvas(
modifier = Modifier
.size(18.dp)
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled && label == null,
role = Role.Checkbox,
onClick = { onCheckedChange(!checked) },
)
) {
val cornerRadius = 3.dp.toPx()
if (checked) {
drawRoundRect(
color = if (enabled) theme.colors.primary else theme.colors.primaryDisabled,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius),
)
// Draw checkmark
val path = Path().apply {
moveTo(size.width * 0.2f, size.height * 0.5f)
lineTo(size.width * 0.42f, size.height * 0.72f)
lineTo(size.width * 0.78f, size.height * 0.28f)
}
drawPath(
path = path,
color = theme.colors.onPrimary.copy(alpha = checkAlpha),
style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round),
)
} else {
drawRoundRect(
color = Color.Transparent,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius),
)
drawRoundRect(
color = if (enabled) theme.colors.border else theme.colors.primaryDisabled,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius),
style = Stroke(width = 1.5.dp.toPx()),
)
}
}
if (label != null) {
AppText(text = label, style = AppTextStyle.BodyMedium, muted = !enabled)
}
}
}
components/AppRadioButton.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Usage:
* ```
* Column {
* AppRadioButton(selected = selected == "a", onClick = { selected = "a" }, label = "Option A")
* AppRadioButton(selected = selected == "b", onClick = { selected = "b" }, label = "Option B")
* }
* ```
*/
@Composable
fun AppRadioButton(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: String? = null,
) {
val theme = appTheme
val dotScale by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = spring(dampingRatio = 0.6f),
label = "radioDot",
)
Row(
modifier = modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = enabled,
role = Role.RadioButton,
onClick = onClick,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Canvas(modifier = Modifier.size(18.dp)) {
val r = size.minDimension / 2
drawCircle(
color = if (enabled) theme.colors.border else theme.colors.primaryDisabled,
radius = r,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1.5.dp.toPx()),
)
if (dotScale > 0f) {
drawCircle(
color = if (enabled) theme.colors.primary else theme.colors.primaryDisabled,
radius = r * 0.5f * dotScale,
)
drawCircle(
color = if (enabled) theme.colors.primary else theme.colors.primaryDisabled,
radius = r,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1.5.dp.toPx()),
)
}
}
if (label != null) {
AppText(text = label, style = AppTextStyle.BodyMedium, muted = !enabled)
}
}
}
components/AppSwitch.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.styles.SwitchDefaults
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Usage:
* ```
* AppSwitch(checked = enabled, onCheckedChange = { enabled = it })
* AppSwitch(checked = enabled, onCheckedChange = { enabled = it }, label = "Dark mode")
* ```
*/
@Composable
fun AppSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: String? = null,
) {
val colors = SwitchDefaults.colors()
val trackColor = if (checked) colors.trackChecked else colors.trackUnchecked
val thumbOffset by animateDpAsState(
targetValue = if (checked) 22.dp else 2.dp,
animationSpec = spring(dampingRatio = 0.6f),
label = "thumbOffset",
)
Row(
modifier = modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = enabled,
role = Role.Switch,
onClick = { onCheckedChange(!checked) },
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(width = 44.dp, height = 24.dp)
.clip(CircleShape)
.background(if (enabled) trackColor else appTheme.colors.primaryDisabled),
) {
Box(
modifier = Modifier
.size(20.dp)
.offset(x = thumbOffset, y = 2.dp)
.clip(CircleShape)
.background(colors.thumb),
)
}
if (label != null) {
AppText(text = label, style = AppTextStyle.BodyMedium, muted = !enabled)
}
}
}
components/AppSlider.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.theme.appTheme
import kotlin.math.roundToInt
/**
* Usage:
* ```
* AppSlider(value = volume, onValueChange = { volume = it }, range = 0f..1f)
* ```
*/
@Composable
fun AppSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
range: ClosedFloatingPointRange<Float> = 0f..1f,
enabled: Boolean = true,
trackColor: Color = appTheme.colors.secondary,
progressColor: Color = appTheme.colors.primary,
thumbColor: Color = appTheme.colors.background,
) {
val theme = appTheme
var trackWidth by remember { mutableStateOf(0) }
val fraction = ((value - range.start) / (range.endInclusive - range.start)).coerceIn(0f, 1f)
val thumbDp = 20.dp
Box(
modifier = modifier
.height(thumbDp)
.padding(horizontal = thumbDp / 2),
contentAlignment = Alignment.CenterStart,
) {
// Track
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { trackWidth = it.width }
.height(4.dp)
.background(trackColor, RoundedCornerShape(2.dp))
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectTapGestures { offset ->
val newFraction = (offset.x / trackWidth).coerceIn(0f, 1f)
onValueChange(range.start + newFraction * (range.endInclusive - range.start))
}
},
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.height(4.dp)
.background(if (enabled) progressColor else theme.colors.primaryDisabled, RoundedCornerShape(2.dp)),
)
}
// Thumb
Box(
modifier = Modifier
.offset { IntOffset(((fraction * trackWidth) - thumbDp.toPx() / 2).roundToInt(), 0) }
.size(thumbDp)
.background(
color = if (enabled) theme.colors.background else theme.colors.primaryDisabled,
shape = CircleShape,
)
.then(
if (enabled) Modifier
.pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
val delta = dragAmount / trackWidth
val newFraction = (fraction + delta).coerceIn(0f, 1f)
onValueChange(range.start + newFraction * (range.endInclusive - range.start))
}
}
else Modifier
),
) {
// Inner dot
Box(
modifier = Modifier
.size(8.dp)
.align(Alignment.Center)
.background(if (enabled) progressColor else theme.colors.primaryDisabled, CircleShape),
)
}
}
}
components/AppSelect.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import GROUP_ID.core.designsystem.theme.appTheme
/**
* Usage:
* ```
* val options = listOf("Option A", "Option B", "Option C")
* AppSelect(
* options = options,
* selected = currentOption,
* onSelect = { currentOption = it },
* placeholder = "Select an option",
* )
* ```
*/
@Composable
fun AppSelect(
options: List<String>,
selected: String?,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Select…",
enabled: Boolean = true,
) {
val theme = appTheme
var expanded by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(theme.shapes.md)
Box(modifier = modifier.zIndex(if (expanded) 1f else 0f)) {
// Trigger
Row(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(theme.colors.background)
.border(
width = if (expanded) 2.dp else 1.dp,
color = if (expanded) theme.colors.borderFocus else theme.colors.border,
shape = shape,
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = enabled,
role = Role.DropdownList,
onClick = { expanded = !expanded },
)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppText(
text = selected ?: placeholder,
style = AppTextStyle.BodyMedium,
color = if (selected != null) theme.colors.onSurface else theme.colors.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
// Chevron
AppText(text = if (expanded) "â–²" else "â–¼", style = AppTextStyle.LabelSmall, muted = true)
}
// Dropdown
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(100)) + expandVertically(tween(100)),
exit = fadeOut(tween(80)) + shrinkVertically(tween(80)),
modifier = Modifier.fillMaxWidth().padding(top = 48.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(8.dp, shape)
.background(theme.colors.background, shape)
.border(1.dp, theme.colors.border, shape)
.padding(vertical = 4.dp),
) {
options.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {
onSelect(option)
expanded = false
},
)
.background(
if (option == selected) theme.colors.secondary else androidx.compose.ui.graphics.Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AppText(
text = option,
style = AppTextStyle.BodyMedium,
modifier = Modifier.weight(1f),
)
if (option == selected) {
AppText(text = "✓", style = AppTextStyle.LabelSmall, color = theme.colors.primary)
}
}
}
}
}
}
}
Step 6: Feedback components
components/AppAlert.kt
package GROUP_ID.core.designsystem.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.style.MutableStyleState
import androidx.compose.foundation.style.Style
import androidx.compose.foundation.style.styleable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import GROUP_ID.core.designsystem.styles.AlertVariant
/**
* Usage:
* ```
* AppAlert(title = "Heads up!", description = "You can add components to your app.")
* AppAlert(
* variant = AlertVariant.Destructive,
* title = "Error",
* description = "Your session has expired. Please sign in again.",
* )
* AppAlert(
* variant = AlertVariant.Warning,
* icon = Icons.Default.Warning,
* title = "Warning",
* description = "This action cannot be undone.",
* )
* ```
*/
@Composable
fun AppAlert(
title: String,
modifier: Modifier = Modifier,
description: String? = null,
icon: ImageVector? = null,
variant: AlertVariant = AlertVariant.Default,
style: Style = Style,
) {
val s
*Truncated - read the full file at https://github.com/ronjunevaldoz/graphyn-editor/blob/ad3e6d1f6468c488faf2f1d404da81bae12d9c85/.agents/skills/kotlin-multiplatform-design-system-extended/SKILL.md.*