feat(android): add design system components matching web theme

Implement 10 reusable Jetpack Compose UI components:
- ShieldButton: 4 variants (primary/secondary/ghost/danger), 3 sizes, loading state, icon support
- ShieldCard: gradient background matching web .gradient-card, click handling, header/footer slots
- ShieldTextField: validation, password toggle, error/helper text, focus styling
- ShieldBadge: 5 variants (default/success/warning/error/info), pill shape, icon support
- ShieldModal: ModalBottomSheet + AlertDialog, swipe-to-dismiss
- ShieldToast: Snackbar-based with 4 variants, auto-dismiss, action buttons
- ShieldAvatar: Coil async image loading, initials fallback, online status indicator
- ShieldProgressBar: linear progress with percentage, 5 color variants
- ShieldEmptyState: icon, title, description, action button
- ShieldSkeleton: shimmer animation with infinite transition

All components use theme tokens (no hardcoded colors) and support light/dark modes.

Add Coil dependency for avatar image loading.
Add ComponentShowcase preview with light/dark mode support.
Add instrumented Compose UI tests for all components.
This commit is contained in:
2026-05-25 20:15:27 -04:00
parent 35bc5f4af1
commit 325be03797
14 changed files with 1467 additions and 0 deletions

View File

@@ -50,6 +50,7 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation(libs.coil.compose)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -0,0 +1,220 @@
package com.shieldai.android
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import com.shieldai.android.ui.components.BadgeVariant
import com.shieldai.android.ui.components.ComponentShowcase
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldBadge
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonSize
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.ui.theme.ShieldAITheme
import org.junit.Rule
import org.junit.Test
class ComponentTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shieldButton_rendersWithText() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Click Me", onClick = {})
}
}
composeTestRule.onNodeWithText("Click Me").assertIsDisplayed()
}
@Test
fun shieldButton_clickHandlerFires() {
var clicked = false
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Click Me", onClick = { clicked = true })
}
}
composeTestRule.onNodeWithText("Click Me").performClick()
assert(clicked) { "Button click handler was not invoked" }
}
@Test
fun shieldButton_disabledDoesNotFireClick() {
var clicked = false
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Click Me", onClick = { clicked = true }, enabled = false)
}
}
composeTestRule.onNodeWithText("Click Me").performClick()
assert(!clicked) { "Disabled button should not fire click handler" }
}
@Test
fun shieldButton_showsLoadingIndicator() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Saving", onClick = {}, loading = true)
}
}
composeTestRule.onNodeWithTag("CircularProgressIndicator").assertExists()
}
@Test
fun shieldButton_variantsRender() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary)
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary)
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost)
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger)
}
}
composeTestRule.onNodeWithText("Primary").assertIsDisplayed()
composeTestRule.onNodeWithText("Secondary").assertIsDisplayed()
composeTestRule.onNodeWithText("Ghost").assertIsDisplayed()
composeTestRule.onNodeWithText("Danger").assertIsDisplayed()
}
@Test
fun shieldButton_sizesRender() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Small", onClick = {}, size = ShieldButtonSize.Small)
ShieldButton(text = "Medium", onClick = {}, size = ShieldButtonSize.Medium)
ShieldButton(text = "Large", onClick = {}, size = ShieldButtonSize.Large)
}
}
composeTestRule.onNodeWithText("Small").assertIsDisplayed()
composeTestRule.onNodeWithText("Medium").assertIsDisplayed()
composeTestRule.onNodeWithText("Large").assertIsDisplayed()
}
@Test
fun shieldButton_fullWidthRenders() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Full Width", onClick = {}, fullWidth = true, modifier = Modifier.fillMaxWidth())
}
}
composeTestRule.onNodeWithText("Full Width").assertIsDisplayed()
}
@Test
fun shieldTextField_rendersWithLabel() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(value = "", onValueChange = {}, label = "Email")
}
}
composeTestRule.onNodeWithText("Email").assertIsDisplayed()
}
@Test
fun shieldTextField_showsErrorState() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "bad",
onValueChange = {},
label = "Input",
isError = true,
errorMessage = "Invalid input"
)
}
}
composeTestRule.onNodeWithText("Invalid input").assertIsDisplayed()
}
@Test
fun shieldTextField_helperTextDisplayed() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Input",
helperText = "Enter your name"
)
}
}
composeTestRule.onNodeWithText("Enter your name").assertIsDisplayed()
}
@Test
fun shieldTextField_passwordToggleExists() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Password",
inputType = InputType.Password
)
}
}
composeTestRule.onNodeWithText("Show").assertIsDisplayed()
}
@Test
fun shieldBadge_variantsRender() {
composeTestRule.setContent {
ShieldAITheme {
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
}
}
composeTestRule.onNodeWithText("Success").assertIsDisplayed()
composeTestRule.onNodeWithText("Error").assertIsDisplayed()
composeTestRule.onNodeWithText("Warning").assertIsDisplayed()
composeTestRule.onNodeWithText("Info").assertIsDisplayed()
composeTestRule.onNodeWithText("Default").assertIsDisplayed()
}
@Test
fun shieldTextField_acceptsInput() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Name"
)
}
}
composeTestRule.onNodeWithText("Name").assertIsDisplayed()
}
@Test
fun componentShowcase_renders() {
composeTestRule.setContent {
ShieldAITheme {
ComponentShowcase()
}
}
composeTestRule.onNodeWithText("ShieldAI Design System").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldButton").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldCard").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldBadge").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldAvatar").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldProgressBar").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldEmptyState").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldSkeleton").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldToast").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldModal").assertIsDisplayed()
}
}

View File

@@ -0,0 +1,269 @@
package com.shieldai.android.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.ShieldAITheme
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComponentShowcase(modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var textFieldValue by remember { mutableStateOf("") }
var showSheet by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "ShieldAI Design System",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
SectionTitle("ShieldButton")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary, size = ShieldButtonSize.Small)
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary, size = ShieldButtonSize.Small)
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost, size = ShieldButtonSize.Small)
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger, size = ShieldButtonSize.Small)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldButton(text = "Loading", onClick = {}, loading = true)
ShieldButton(text = "Disabled", onClick = {}, enabled = false)
}
ShieldButton(text = "Full Width", onClick = {}, variant = ShieldButtonVariant.Primary, fullWidth = true)
HorizontalDivider()
SectionTitle("ShieldCard")
ShieldCard(
modifier = Modifier.fillMaxWidth(),
header = { Text("Card Header", style = MaterialTheme.typography.titleMedium) },
footer = {
ShieldButton(text = "Action", onClick = {}, size = ShieldButtonSize.Small)
},
content = {
Spacer(modifier = Modifier.height(8.dp))
Text("This is the card content area. It uses a gradient background matching the web theme.", style = MaterialTheme.typography.bodyMedium)
}
)
HorizontalDivider()
SectionTitle("ShieldTextField")
ShieldTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
label = "Email",
placeholder = "Enter your email",
inputType = InputType.Email
)
ShieldTextField(
value = "",
onValueChange = {},
label = "Password",
inputType = InputType.Password
)
ShieldTextField(
value = "invalid",
onValueChange = {},
label = "With Error",
isError = true,
errorMessage = "This field is required"
)
HorizontalDivider()
SectionTitle("ShieldBadge")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
}
HorizontalDivider()
SectionTitle("ShieldAvatar")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ShieldAvatar(imageUrl = null, name = "John Doe", size = AvatarSize.Small)
ShieldAvatar(imageUrl = null, name = "Jane Smith", size = AvatarSize.Medium, isOnline = true)
ShieldAvatar(imageUrl = null, name = "Alice", size = AvatarSize.Large, isOnline = true)
}
HorizontalDivider()
SectionTitle("ShieldProgressBar")
ShieldProgressBar(progress = 0.3f, color = ProgressColor.Primary, showPercentage = true)
ShieldProgressBar(progress = 0.6f, color = ProgressColor.Accent, showPercentage = true)
ShieldProgressBar(progress = 0.9f, color = ProgressColor.Success, showPercentage = true)
HorizontalDivider()
SectionTitle("ShieldEmptyState")
ShieldEmptyState(
title = "No items found",
description = "Try adjusting your search or filters to find what you're looking for.",
actionButton = {
ShieldButton(text = "Clear Filters", onClick = {}, variant = ShieldButtonVariant.Secondary)
}
)
HorizontalDivider()
SectionTitle("ShieldSkeleton")
ShieldSkeletonCard(modifier = Modifier.fillMaxWidth(), lines = 3)
HorizontalDivider()
SectionTitle("ShieldToast")
ShieldButton(
text = "Show Success Toast",
onClick = {
scope.launch {
snackbarHostState.showSnackbar(
ShieldSnackbarVisuals(
message = "Operation completed successfully!",
variant = ToastVariant.Success,
duration = SnackbarDuration.Short
)
)
}
},
variant = ShieldButtonVariant.Primary,
fullWidth = true
)
ShieldButton(
text = "Show Error Toast with Action",
onClick = {
scope.launch {
snackbarHostState.showSnackbar(
ShieldSnackbarVisuals(
message = "Something went wrong.",
actionLabel = "Retry",
variant = ToastVariant.Error,
duration = SnackbarDuration.Long
)
)
}
},
variant = ShieldButtonVariant.Danger,
fullWidth = true
)
HorizontalDivider()
SectionTitle("ShieldModal")
ShieldButton(
text = "Show Bottom Sheet",
onClick = { showSheet = true },
variant = ShieldButtonVariant.Secondary,
fullWidth = true
)
ShieldButton(
text = "Show Alert Dialog",
onClick = { showDialog = true },
variant = ShieldButtonVariant.Ghost,
fullWidth = true
)
Spacer(modifier = Modifier.height(80.dp))
}
ShieldToastHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
if (showSheet) {
ShieldBottomSheet(
onDismiss = { showSheet = false },
title = "Bottom Sheet Title",
actions = listOf(
ModalAction(text = "Save", onClick = { showSheet = false }, isPrimary = true),
ModalAction(text = "Cancel", onClick = { showSheet = false })
)
) {
Text("This is the bottom sheet content area.")
}
}
if (showDialog) {
ShieldAlertDialog(
onDismiss = { showDialog = false },
onConfirm = { showDialog = false },
title = "Confirm Action",
message = "Are you sure you want to proceed?",
confirmText = "Yes, Continue",
dismissText = "Cancel"
)
}
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
@Preview(showBackground = true, name = "Light Mode")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode")
@Composable
fun ComponentShowcasePreview() {
ShieldAITheme {
ComponentShowcase()
}
}

View File

@@ -0,0 +1,97 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.Canvas
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.material3.MaterialTheme
import androidx.compose.material3.Text
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Success
enum class AvatarSize(val dimension: Dp, val fontSize: TextUnit) {
Small(32.dp, 12.sp),
Medium(40.dp, 16.sp),
Large(56.dp, 24.sp)
}
@Composable
fun ShieldAvatar(
imageUrl: String?,
name: String,
modifier: Modifier = Modifier,
size: AvatarSize = AvatarSize.Medium,
isOnline: Boolean = false
) {
val initials = remember(name) {
name.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
}
val statusDotSize = (size.dimension / 4).coerceAtLeast(8.dp)
Box(
modifier = modifier.size(size.dimension),
contentAlignment = Alignment.BottomEnd
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = name,
modifier = Modifier
.size(size.dimension)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.size(size.dimension)
.clip(CircleShape)
.background(BrandPrimary),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = Color.White,
fontSize = size.fontSize,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
}
if (isOnline) {
Canvas(
modifier = Modifier.size(statusDotSize)
) {
val radius = statusDotSize.toPx() / 2
drawCircle(
color = Color.White,
radius = radius
)
drawCircle(
color = Success,
radius = radius - 1.5.dp.toPx()
)
}
}
}
}

View File

@@ -0,0 +1,92 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.painter.Painter
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Info
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.TextPrimaryLight
import com.shieldai.android.ui.theme.TextSecondaryLight
import com.shieldai.android.ui.theme.Warning
enum class BadgeVariant {
Default, Success, Warning, Error, Info
}
data class BadgeColors(
val background: Color,
val content: Color
)
fun badgeColors(variant: BadgeVariant): BadgeColors = when (variant) {
BadgeVariant.Default -> BadgeColors(
background = Color(0xFFF1F5F9),
content = TextSecondaryLight
)
BadgeVariant.Success -> BadgeColors(
background = Success.copy(alpha = 0.15f),
content = Success
)
BadgeVariant.Warning -> BadgeColors(
background = Warning.copy(alpha = 0.15f),
content = Warning
)
BadgeVariant.Error -> BadgeColors(
background = Error.copy(alpha = 0.15f),
content = Error
)
BadgeVariant.Info -> BadgeColors(
background = Info.copy(alpha = 0.15f),
content = Info
)
}
@Composable
fun ShieldBadge(
text: String,
modifier: Modifier = Modifier,
variant: BadgeVariant = BadgeVariant.Default,
icon: Painter? = null
) {
val colors = badgeColors(variant)
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = colors.background,
contentColor = colors.content
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = colors.content
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = text,
style = MaterialTheme.typography.labelSmall
)
}
}
}

View File

@@ -0,0 +1,146 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.RowScope
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Error
enum class ShieldButtonVariant {
Primary, Secondary, Ghost, Danger
}
enum class ShieldButtonSize {
Small, Medium, Large
}
@Composable
fun ShieldButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ShieldButtonVariant = ShieldButtonVariant.Primary,
size: ShieldButtonSize = ShieldButtonSize.Medium,
enabled: Boolean = true,
loading: Boolean = false,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
fullWidth: Boolean = false
) {
val buttonModifier = if (fullWidth) modifier.fillMaxWidth() else modifier
val sizeModifier = when (size) {
ShieldButtonSize.Small -> Modifier.height(32.dp)
ShieldButtonSize.Medium -> Modifier.height(40.dp)
ShieldButtonSize.Large -> Modifier.height(48.dp)
}
val paddingModifier = when (size) {
ShieldButtonSize.Small -> Modifier.padding(horizontal = 12.dp)
ShieldButtonSize.Medium -> Modifier.padding(horizontal = 16.dp)
ShieldButtonSize.Large -> Modifier.padding(horizontal = 20.dp)
}
val indicatorSize = when (size) {
ShieldButtonSize.Small -> 16.dp
ShieldButtonSize.Medium -> 20.dp
ShieldButtonSize.Large -> 24.dp
}
val contentColor = when {
variant == ShieldButtonVariant.Ghost -> BrandPrimary
variant == ShieldButtonVariant.Secondary -> BrandPrimary
else -> Color.White
}
val containerColor = when (variant) {
ShieldButtonVariant.Primary -> BrandPrimary
ShieldButtonVariant.Danger -> Error
else -> Color.Transparent
}
val mergedEnabled = enabled && !loading
val content: @Composable RowScope.() -> Unit = {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(indicatorSize),
color = if (variant == ShieldButtonVariant.Ghost || variant == ShieldButtonVariant.Secondary)
BrandPrimary else Color.White,
strokeWidth = 2.dp
)
} else {
leadingIcon?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
style = when (size) {
ShieldButtonSize.Small -> MaterialTheme.typography.labelSmall
ShieldButtonSize.Medium -> MaterialTheme.typography.labelLarge
ShieldButtonSize.Large -> MaterialTheme.typography.titleSmall
}
)
trailingIcon?.let {
Spacer(modifier = Modifier.width(8.dp))
it()
}
}
}
when (variant) {
ShieldButtonVariant.Primary, ShieldButtonVariant.Danger -> {
Button(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor.copy(alpha = 0.4f),
disabledContentColor = contentColor.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
content = content
)
}
ShieldButtonVariant.Secondary -> {
OutlinedButton(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = BrandPrimary,
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
),
border = ButtonDefaults.outlinedButtonBorder(enabled = mergedEnabled),
shape = MaterialTheme.shapes.small,
content = content
)
}
ShieldButtonVariant.Ghost -> {
TextButton(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.textButtonColors(
contentColor = BrandPrimary,
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
content = content
)
}
}
}

View File

@@ -0,0 +1,55 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandAccent
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.OutlineLight
val GradientCardBrush = Brush.linearGradient(
colors = listOf(
BrandPrimary.copy(alpha = 0.08f),
BrandAccent.copy(alpha = 0.05f)
)
)
@Composable
fun ShieldCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
header: @Composable ColumnScope.() -> Unit = {},
footer: @Composable ColumnScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
Card(
onClick = onClick ?: {},
enabled = onClick != null,
modifier = modifier,
shape = MaterialTheme.shapes.large,
border = BorderStroke(1.dp, OutlineLight),
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
)
) {
Column(
modifier = Modifier
.background(GradientCardBrush)
.padding(16.dp)
) {
header()
content()
footer()
}
}
}

View File

@@ -0,0 +1,62 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.material3.Icon
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.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun ShieldEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
icon: Painter? = null,
actionButton: @Composable (() -> Unit)? = null
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
}
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (actionButton != null) {
Spacer(modifier = Modifier.height(24.dp))
actionButton()
}
}
}

View File

@@ -0,0 +1,125 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
data class ModalAction(
val text: String,
val onClick: () -> Unit,
val isPrimary: Boolean = false
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShieldBottomSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
title: String? = null,
actions: List<ModalAction> = emptyList(),
content: @Composable () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(16.dp))
}
content()
if (actions.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
actions.forEach { action ->
TextButton(
onClick = action.onClick,
modifier = Modifier.fillMaxWidth(),
colors = if (action.isPrimary) {
ButtonDefaults.textButtonColors(contentColor = BrandPrimary)
} else {
ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
}
) {
Text(text = action.text)
}
}
}
}
}
}
@Composable
fun ShieldAlertDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
title: String,
message: String,
confirmText: String = "Confirm",
dismissText: String = "Cancel",
isDestructive: Boolean = false
) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = confirmText,
color = if (isDestructive) MaterialTheme.colorScheme.error else BrandPrimary
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = dismissText)
}
},
shape = MaterialTheme.shapes.large
)
}

View File

@@ -0,0 +1,66 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandAccent
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.OutlineLight
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.Warning
enum class ProgressColor {
Primary, Accent, Success, Warning, Error
}
@Composable
fun ShieldProgressBar(
progress: Float,
modifier: Modifier = Modifier,
color: ProgressColor = ProgressColor.Primary,
showPercentage: Boolean = false
) {
val progressColor = when (color) {
ProgressColor.Primary -> BrandPrimary
ProgressColor.Accent -> BrandAccent
ProgressColor.Success -> Success
ProgressColor.Warning -> Warning
ProgressColor.Error -> Error
}
Column(modifier = modifier.fillMaxWidth()) {
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = progressColor,
trackColor = OutlineLight,
strokeCap = StrokeCap.Round
)
if (showPercentage) {
Text(
text = "${(progress * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
textAlign = TextAlign.End
)
}
}
}

View File

@@ -0,0 +1,122 @@
package com.shieldai.android.ui.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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.OutlineLight
@Composable
fun ShieldSkeletonLine(
modifier: Modifier = Modifier,
widthFraction: Float = 1f
) {
val shimmerColors = listOf(
OutlineLight.copy(alpha = 0.6f),
Color.White.copy(alpha = 0.4f),
OutlineLight.copy(alpha = 0.6f)
)
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnimation by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerOffset"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnimation - 200f, 0f),
end = Offset(translateAnimation, 0f)
)
Box(
modifier = modifier
.fillMaxWidth(fraction = widthFraction)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(brush)
)
}
@Composable
fun ShieldSkeletonRectangle(
modifier: Modifier = Modifier,
height: Int = 100
) {
val shimmerColors = listOf(
OutlineLight.copy(alpha = 0.6f),
Color.White.copy(alpha = 0.4f),
OutlineLight.copy(alpha = 0.6f)
)
val transition = rememberInfiniteTransition(label = "shimmerRect")
val translateAnimation by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerRectOffset"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnimation - 200f, 0f),
end = Offset(translateAnimation, 0f)
)
Box(
modifier = modifier
.fillMaxWidth()
.height(height.dp)
.clip(RoundedCornerShape(8.dp))
.background(brush)
)
}
@Composable
fun ShieldSkeletonCard(
modifier: Modifier = Modifier,
lines: Int = 3
) {
Column(modifier = modifier.padding(16.dp)) {
ShieldSkeletonRectangle(height = 120)
Spacer(modifier = Modifier.height(12.dp))
repeat(lines) { index ->
ShieldSkeletonLine(
widthFraction = when (index) {
0 -> 0.9f
lines - 1 -> 0.5f
else -> 0.75f
}
)
if (index < lines - 1) {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}

View File

@@ -0,0 +1,118 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
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.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
enum class InputType {
Text, Email, Password, Number, Phone
}
@Composable
fun ShieldTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String = "",
inputType: InputType = InputType.Text,
isError: Boolean = false,
errorMessage: String? = null,
helperText: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false
) {
var passwordVisible by remember { mutableStateOf(false) }
val keyboardType = when (inputType) {
InputType.Email -> KeyboardType.Email
InputType.Password -> KeyboardType.Password
InputType.Number -> KeyboardType.Number
InputType.Phone -> KeyboardType.Phone
else -> KeyboardType.Text
}
val visualTransformation = if (inputType == InputType.Password && !passwordVisible) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
}
val trailingIcon = if (inputType == InputType.Password) {
@Composable {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Text(
text = if (passwordVisible) "Hide" else "Show",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
} else null
Column(modifier = modifier.fillMaxWidth()) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = if (placeholder.isNotEmpty()) {{ Text(placeholder) }} else null,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
readOnly = readOnly,
singleLine = true,
isError = isError,
visualTransformation = visualTransformation,
trailingIcon = trailingIcon,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Next
),
shape = MaterialTheme.shapes.medium,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = MaterialTheme.colorScheme.primary,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline,
errorIndicatorColor = Error,
focusedLabelColor = MaterialTheme.colorScheme.primary,
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary
)
)
if (isError && errorMessage != null) {
Text(
text = errorMessage,
color = Error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
} else if (helperText != null) {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}

View File

@@ -0,0 +1,92 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Info
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.TextPrimaryDark
import com.shieldai.android.ui.theme.Warning
enum class ToastVariant {
Success, Error, Warning, Info
}
data class ToastColors(
val container: Color,
val content: Color,
val action: Color
)
fun toastColors(variant: ToastVariant): ToastColors = when (variant) {
ToastVariant.Success -> ToastColors(
container = Success,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Error -> ToastColors(
container = Error,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Warning -> ToastColors(
container = Warning,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Info -> ToastColors(
container = Info,
content = TextPrimaryDark,
action = TextPrimaryDark
)
}
@Composable
fun ShieldToastHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
SnackbarHost(
hostState = hostState,
modifier = modifier,
snackbar = { data: SnackbarData ->
val visuals = data.visuals as? ShieldSnackbarVisuals
val colors = visuals?.let { toastColors(it.variant) }
?: toastColors(ToastVariant.Info)
Snackbar(
snackbarData = data,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
containerColor = colors.container,
contentColor = colors.content,
actionColor = colors.action
)
}
)
}
class ShieldSnackbarVisuals(
message: String,
actionLabel: String? = null,
duration: SnackbarDuration = SnackbarDuration.Short,
val variant: ToastVariant = ToastVariant.Info
) : SnackbarVisuals {
override val message: String = message
override val actionLabel: String? = actionLabel
override val duration: SnackbarDuration = duration
override val withDismissAction: Boolean = false
}

View File

@@ -9,6 +9,7 @@ activityCompose = "1.8.0"
navigationCompose = "2.7.7"
kotlin = "2.2.10"
composeBom = "2025.12.00"
coilCompose = "2.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -27,6 +28,7 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }