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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user