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.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
|
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
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"
|
navigationCompose = "2.7.7"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2025.12.00"
|
composeBom = "2025.12.00"
|
||||||
|
coilCompose = "2.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user