diff --git a/android/ShieldAI/app/build.gradle.kts b/android/ShieldAI/app/build.gradle.kts index bad07e0..905c704 100644 --- a/android/ShieldAI/app/build.gradle.kts +++ b/android/ShieldAI/app/build.gradle.kts @@ -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) diff --git a/android/ShieldAI/app/src/androidTest/java/com/shieldai/android/ComponentTests.kt b/android/ShieldAI/app/src/androidTest/java/com/shieldai/android/ComponentTests.kt new file mode 100644 index 0000000..c31b315 --- /dev/null +++ b/android/ShieldAI/app/src/androidTest/java/com/shieldai/android/ComponentTests.kt @@ -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() + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ComponentShowcase.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ComponentShowcase.kt new file mode 100644 index 0000000..8e02062 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ComponentShowcase.kt @@ -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() + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldAvatar.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldAvatar.kt new file mode 100644 index 0000000..fd1fd59 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldAvatar.kt @@ -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() + ) + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldBadge.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldBadge.kt new file mode 100644 index 0000000..10e47b2 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldBadge.kt @@ -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 + ) + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldButton.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldButton.kt new file mode 100644 index 0000000..f8730dd --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldButton.kt @@ -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 + ) + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldCard.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldCard.kt new file mode 100644 index 0000000..0ae85ea --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldCard.kt @@ -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() + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldEmptyState.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldEmptyState.kt new file mode 100644 index 0000000..f17c81a --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldEmptyState.kt @@ -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() + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldModal.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldModal.kt new file mode 100644 index 0000000..d432bfd --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldModal.kt @@ -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 = 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 + ) +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldProgressBar.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldProgressBar.kt new file mode 100644 index 0000000..489a23b --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldProgressBar.kt @@ -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 + ) + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldSkeleton.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldSkeleton.kt new file mode 100644 index 0000000..3de46e1 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldSkeleton.kt @@ -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)) + } + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldTextField.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldTextField.kt new file mode 100644 index 0000000..da488b4 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldTextField.kt @@ -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) + ) + } + } +} diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldToast.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldToast.kt new file mode 100644 index 0000000..1b5eac8 --- /dev/null +++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/ui/components/ShieldToast.kt @@ -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 +} diff --git a/android/ShieldAI/gradle/libs.versions.toml b/android/ShieldAI/gradle/libs.versions.toml index 44af5c8..7b24a70 100644 --- a/android/ShieldAI/gradle/libs.versions.toml +++ b/android/ShieldAI/gradle/libs.versions.toml @@ -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" }