From 15dd1d0839b73432f2d26754ead6e37e55d7f826 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:34:10 +0000 Subject: [PATCH] Reimagine the UI with the "Nocturne" design language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ground-up visual redesign around the idea that Hush is the moment after the noise stops — hushed, editorial, restrained: - Typography: serif (system Noto Serif) display/headline/title-large styles for an editorial voice; sans for everything read at speed - Color: light theme becomes warm linen paper with a deep dusk-indigo primary; dark theme a near-black midnight sky with moonlit lilac. The six candy accents collapse into a dusty semantic set (ember red = block, dusk gold = mute, sage = allow; slate/plum/teal metadata) - Surfaces: flat cards with hairline borders and larger radii replace tonal fills and drop shadows; new QuietSurface component - Navigation: floating pill bottom bar with an expanding filled pill for the selected tab, replacing the stock NavigationBar - Chat: solid ink user bubbles, serif "h" monogram avatar, bordered pill input, theme-aware AI status bubbles (drops the light-only pastel hacks so dark mode renders correctly) - Rules: action-colored edge bars, outlined attribute chips - History: serif italic datelines, serif monogram discs, tint-based status badges that work in both themes - Settings: sections grouped into hairline-bordered islands with soft-washed icon tiles - Onboarding: dusk-gradient hero reserved as the app's one gradient moment, serif wordmark and italic tagline All test tags, user-visible strings relied on by tests, and ViewModel contracts are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N5ZagcBdYgvCwzCpJGRBH1 --- .../hush/app/ui/components/HushComponents.kt | 89 ++- .../com/hush/app/ui/screens/MainScreen.kt | 131 ++- .../hush/app/ui/screens/chat/ChatScreen.kt | 214 +++-- .../app/ui/screens/history/HistoryScreen.kt | 116 +-- .../ui/screens/onboarding/OnboardingScreen.kt | 58 +- .../hush/app/ui/screens/rules/RulesScreen.kt | 73 +- .../app/ui/screens/settings/SettingsScreen.kt | 755 +++++++++--------- .../main/java/com/hush/app/ui/theme/Color.kt | 125 ++- .../main/java/com/hush/app/ui/theme/Theme.kt | 81 +- .../main/java/com/hush/app/ui/theme/Type.kt | 53 +- 10 files changed, 884 insertions(+), 811 deletions(-) diff --git a/app/src/main/java/com/hush/app/ui/components/HushComponents.kt b/app/src/main/java/com/hush/app/ui/components/HushComponents.kt index 80e835e..ef514bb 100644 --- a/app/src/main/java/com/hush/app/ui/components/HushComponents.kt +++ b/app/src/main/java/com/hush/app/ui/components/HushComponents.kt @@ -1,6 +1,8 @@ package com.hush.app.ui.components +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape 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 @@ -24,57 +27,40 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.material3.Icon -import com.hush.app.ui.theme.AccentPurple +import com.hush.app.ui.theme.PlumMist -/** Gradient used for brand moments (logo chip, hero icons, user bubbles). */ -val HushGradient = Brush.linearGradient( - listOf(Color(0xFF7C5CFC), Color(0xFF9E7BFF), Color(0xFF5CA8FC)) +/** Dusk-sky gradient, reserved for rare brand moments (onboarding hero). */ +val DuskGradient = Brush.linearGradient( + listOf(Color(0xFF3E3663), Color(0xFF6B5490), Color(0xFF96688F)) ) /** - * Large screen header used at the top of every tab: bold title, quiet - * subtitle, and an optional trailing element (status pill, count badge). + * Editorial screen header: a large serif title over a quiet one-line + * subtitle, with an optional trailing element (status pill, action). */ @Composable fun HushHeader( title: String, subtitle: String, modifier: Modifier = Modifier, - leadingIcon: ImageVector? = null, trailing: (@Composable () -> Unit)? = null ) { Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 14.dp), + .padding(horizontal = 24.dp) + .padding(top = 24.dp, bottom = 14.dp), verticalAlignment = Alignment.CenterVertically ) { - if (leadingIcon != null) { - Box( - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(14.dp)) - .background(HushGradient), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = leadingIcon, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - } Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground ) - Spacer(modifier = Modifier.height(1.dp)) + Spacer(modifier = Modifier.height(3.dp)) Text( text = subtitle, style = MaterialTheme.typography.bodySmall, @@ -88,17 +74,17 @@ fun HushHeader( } } -/** Small colored status pill, e.g. "● On-device AI". */ +/** Small status pill: hairline ring, soft wash, and a colored dot. */ @Composable fun StatusPill( label: String, color: Color, - background: Color, modifier: Modifier = Modifier ) { Surface( shape = CircleShape, - color = background, + color = color.copy(alpha = 0.08f), + border = BorderStroke(1.dp, color.copy(alpha = 0.28f)), modifier = modifier ) { Row( @@ -108,7 +94,7 @@ fun StatusPill( ) { Box( modifier = Modifier - .size(7.dp) + .size(6.dp) .clip(CircleShape) .background(color) ) @@ -122,17 +108,18 @@ fun StatusPill( } } -/** Tiny labeled chip used inside cards to show rule attributes. */ +/** Tiny outlined chip used inside cards to show rule attributes. */ @Composable fun AttributeChip( icon: ImageVector?, label: String, - color: Color = AccentPurple, + color: Color = PlumMist, modifier: Modifier = Modifier ) { Surface( shape = RoundedCornerShape(8.dp), - color = color.copy(alpha = 0.12f), + color = Color.Transparent, + border = BorderStroke(1.dp, color.copy(alpha = 0.35f)), modifier = modifier ) { Row( @@ -158,7 +145,25 @@ fun AttributeChip( } } -/** Shared empty-state layout: soft icon disc, title, and helper text. */ +/** Flat card with a hairline border — the Nocturne surface idiom. */ +@Composable +fun QuietSurface( + modifier: Modifier = Modifier, + shape: androidx.compose.ui.graphics.Shape = MaterialTheme.shapes.large, + color: Color = MaterialTheme.colorScheme.surfaceVariant, + borderColor: Color = MaterialTheme.colorScheme.outline, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier, + shape = shape, + color = color, + border = BorderStroke(1.dp, borderColor), + content = content + ) +} + +/** Shared empty-state layout: outlined icon ring, serif title, helper text. */ @Composable fun EmptyState( icon: ImageVector, @@ -174,28 +179,30 @@ fun EmptyState( modifier = Modifier .size(88.dp) .clip(CircleShape) - .background(AccentPurple.copy(alpha = 0.10f)), + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(40.dp), - tint = AccentPurple.copy(alpha = 0.8f) + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } Spacer(modifier = Modifier.height(20.dp)) Text( title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(6.dp)) Text( message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center + textAlign = TextAlign.Center ) } } diff --git a/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt b/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt index bbaae3a..6a3a686 100644 --- a/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/MainScreen.kt @@ -1,22 +1,32 @@ package com.hush.app.ui.screens +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost @@ -43,48 +53,47 @@ fun MainScreen( ) Scaffold( + containerColor = MaterialTheme.colorScheme.background, bottomBar = { - NavigationBar( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp + // Floating pill navigation — a single quiet island instead of a + // full-width bar. Selected tab expands into a filled pill. + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 14.dp), + contentAlignment = Alignment.Center ) { - val navBackStackEntry by childNavController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + ) { + val navBackStackEntry by childNavController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route - tabs.forEach { tab -> - val selected = currentRoute == tab.route - NavigationBarItem( - icon = { - Icon( - if (selected) tab.selectedIcon else tab.icon, - contentDescription = tab.title - ) - }, - label = { - Text( - tab.title, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium - ) - }, - selected = selected, - colors = NavigationBarItemDefaults.colors( - indicatorColor = MaterialTheme.colorScheme.secondaryContainer, - selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, - selectedTextColor = MaterialTheme.colorScheme.onSurface, - unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - onClick = { - childNavController.navigate(tab.route) { - popUpTo(childNavController.graph.findStartDestination().id) { - saveState = true + Row( + modifier = Modifier.padding(6.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + tabs.forEach { tab -> + NavPill( + tab = tab, + selected = currentRoute == tab.route, + onClick = { + childNavController.navigate(tab.route) { + popUpTo(childNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } } - launchSingleTop = true - restoreState = true - } - }, - modifier = Modifier.testTag("bottom_nav_${tab.route}") - ) + ) + } + } } } }, @@ -94,7 +103,7 @@ fun MainScreen( navController = childNavController, startDestination = BottomTabRoute.Chat.route, modifier = Modifier.padding(innerPadding), - // Gentle fade + settle between tabs — modern, not distracting + // Gentle fade + settle between tabs — calm, not distracting enterTransition = { fadeIn(tween(220)) + scaleIn(initialScale = 0.985f, animationSpec = tween(220)) }, @@ -115,3 +124,39 @@ fun MainScreen( } } } + +@Composable +private fun NavPill( + tab: BottomTabRoute, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = CircleShape, + color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("bottom_nav_${tab.route}") + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .animateContentSize(animationSpec = tween(200)) + .padding(horizontal = if (selected) 16.dp else 12.dp, vertical = 10.dp) + ) { + Icon( + imageVector = if (selected) tab.selectedIcon else tab.icon, + contentDescription = tab.title, + modifier = Modifier.size(20.dp) + ) + if (selected) { + Spacer(modifier = Modifier.width(7.dp)) + Text( + text = tab.title, + style = MaterialTheme.typography.labelLarge + ) + } + } + } +} diff --git a/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt b/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt index 33f8ab7..31ed329 100644 --- a/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/chat/ChatScreen.kt @@ -3,16 +3,15 @@ package com.hush.app.ui.screens.chat import android.Manifest import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable @@ -27,7 +26,6 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Block -import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Search @@ -39,15 +37,14 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.unit.offset import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -59,7 +56,6 @@ import com.hush.app.domain.model.ParsedCommand import com.hush.app.domain.model.RuleAction import com.hush.app.domain.repository.AIStatus import com.hush.app.ui.components.AttributeChip -import com.hush.app.ui.components.HushGradient import com.hush.app.ui.components.HushHeader import com.hush.app.ui.components.StatusPill import com.hush.app.ui.theme.* @@ -119,14 +115,13 @@ fun ChatScreen( ) { HushHeader( title = "Hush", - subtitle = "Talk to your notifications", - leadingIcon = Icons.Outlined.NotificationsOff, + subtitle = "Tell your phone what deserves attention", trailing = { when (aiStatus) { - AIStatus.READY -> StatusPill("Gemini Nano", AccentGreen, AccentGreen.copy(alpha = 0.12f)) - AIStatus.CHECKING -> StatusPill("Checking…", AccentAmber, AccentAmber.copy(alpha = 0.12f)) - AIStatus.DOWNLOADING -> StatusPill("Downloading", AccentBlue, AccentBlue.copy(alpha = 0.12f)) - else -> StatusPill("Basic mode", AccentPurple, AccentPurple.copy(alpha = 0.12f)) + AIStatus.READY -> StatusPill("Gemini Nano", SageGreen) + AIStatus.CHECKING -> StatusPill("Checking…", DuskGold) + AIStatus.DOWNLOADING -> StatusPill("Downloading", SlateBlue) + else -> StatusPill("Basic mode", PlumMist) } } ) @@ -168,29 +163,29 @@ fun ChatScreen( AIStatus.NOT_DOWNLOADED -> { AiStatusBubble( modifier = Modifier.testTag("ai_download_banner"), - containerColor = AccentAmberLight + accent = DuskGold ) { Column { Text( "Set up on-device AI", style = MaterialTheme.typography.titleSmall, - color = CardOnLight + color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(6.dp)) Text( "Hush can download Gemini Nano (~350 MB) for smarter command understanding. Until then, a built-in parser handles simple commands.", style = MaterialTheme.typography.bodySmall, - color = CardOnLightMuted + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = { viewModel.startModelDownload() }, modifier = Modifier.testTag("ai_download_button"), - shape = RoundedCornerShape(20.dp), + shape = CircleShape, colors = ButtonDefaults.buttonColors( - containerColor = AccentAmber, - contentColor = CardOnLight + containerColor = DuskGold, + contentColor = Color.White ) ) { Text("Set Up AI", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) @@ -198,10 +193,10 @@ fun ChatScreen( OutlinedButton( onClick = { viewModel.openAICoreUpdateInStore(context) }, modifier = Modifier.testTag("ai_update_button"), - shape = RoundedCornerShape(20.dp), - border = BorderStroke(1.dp, CardOnLightMuted), + shape = CircleShape, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), colors = ButtonDefaults.outlinedButtonColors( - contentColor = CardOnLight + contentColor = MaterialTheme.colorScheme.onSurface ) ) { Text("Check for Updates", fontSize = 13.sp) @@ -214,27 +209,27 @@ fun ChatScreen( AIStatus.DOWNLOADING -> { AiStatusBubble( modifier = Modifier.testTag("ai_downloading_banner"), - containerColor = AccentBlueLight + accent = SlateBlue ) { Column { Text( "Downloading Gemini Nano…", style = MaterialTheme.typography.titleSmall, - color = CardOnLight + color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(4.dp)) Text( "This may take a few minutes. Please stay on WiFi.", style = MaterialTheme.typography.bodySmall, - color = CardOnLightMuted + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(10.dp)) LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(4.dp)), - color = AccentBlue, - trackColor = AccentBlue.copy(alpha = 0.2f) + color = SlateBlue, + trackColor = SlateBlue.copy(alpha = 0.2f) ) } } @@ -243,7 +238,7 @@ fun ChatScreen( AIStatus.ERROR -> { AiStatusBubble( modifier = Modifier.testTag("ai_error_banner"), - containerColor = MaterialTheme.colorScheme.errorContainer + accent = MaterialTheme.colorScheme.error ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -256,12 +251,12 @@ fun ChatScreen( Column(modifier = Modifier.weight(1f)) { Text( "AI engine encountered an error", - color = MaterialTheme.colorScheme.onErrorContainer, + color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall ) Text( aiErrorMessage ?: "This may be temporary. Tap Retry to try again.", - color = MaterialTheme.colorScheme.onErrorContainer, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall ) } @@ -269,7 +264,7 @@ fun ChatScreen( FilledTonalButton( onClick = { viewModel.retryAICheck() }, modifier = Modifier.testTag("ai_retry_button"), - shape = RoundedCornerShape(20.dp) + shape = CircleShape ) { Icon(Icons.Default.Refresh, contentDescription = "Retry", modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) @@ -282,20 +277,20 @@ fun ChatScreen( AIStatus.NOT_SUPPORTED -> { AiStatusBubble( modifier = Modifier.testTag("ai_unsupported_banner"), - containerColor = AccentPurpleLight + accent = PlumMist ) { Column { Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Outlined.AutoAwesome, contentDescription = "Basic mode", - tint = AccentPurple, + tint = PlumMist, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( "Running in basic mode", - color = CardOnLight, + color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall ) } @@ -303,7 +298,7 @@ fun ChatScreen( Text( aiErrorMessage ?: "Gemini Nano isn't available on this device, so Hush uses its built-in parser. Simple commands like \"Mute Instagram\" or \"Block Slack after 6pm\" work great.", - color = CardOnLightMuted, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall ) Spacer(modifier = Modifier.height(12.dp)) @@ -311,10 +306,10 @@ fun ChatScreen( Button( onClick = { viewModel.retryAICheck() }, modifier = Modifier.testTag("ai_retry_button"), - shape = RoundedCornerShape(20.dp), + shape = CircleShape, colors = ButtonDefaults.buttonColors( - containerColor = AccentPurple, - contentColor = Color.White + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary ) ) { Icon(Icons.Default.Refresh, contentDescription = "Retry", modifier = Modifier.size(16.dp)) @@ -324,10 +319,10 @@ fun ChatScreen( OutlinedButton( onClick = { viewModel.openAICoreUpdateInStore(context) }, modifier = Modifier.testTag("ai_update_button"), - shape = RoundedCornerShape(20.dp), - border = BorderStroke(1.dp, CardOnLightMuted), + shape = CircleShape, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), colors = ButtonDefaults.outlinedButtonColors( - contentColor = CardOnLight + contentColor = MaterialTheme.colorScheme.onSurface ) ) { Text("Check for Updates") @@ -372,7 +367,7 @@ fun ChatScreen( ) { Box( modifier = Modifier - .clip(RoundedCornerShape(18.dp, 18.dp, 18.dp, 6.dp)) + .clip(RoundedCornerShape(20.dp, 20.dp, 20.dp, 6.dp)) .background(MaterialTheme.colorScheme.errorContainer) .padding(12.dp) .testTag("chat_error_message") @@ -390,14 +385,14 @@ fun ChatScreen( // Show voice waveform UI if (isListening) { item { - Card( + val primaryColor = MaterialTheme.colorScheme.primary + Surface( modifier = Modifier .fillMaxWidth() .testTag("voice_waveform_ui"), - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors( - containerColor = AccentPurpleLight - ) + shape = MaterialTheme.shapes.large, + color = primaryColor.copy(alpha = 0.08f), + border = BorderStroke(1.dp, primaryColor.copy(alpha = 0.25f)) ) { Column( modifier = Modifier @@ -408,10 +403,9 @@ fun ChatScreen( Text( text = "Listening…", style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), - color = AccentPurple, + color = primaryColor, modifier = Modifier.padding(bottom = 8.dp) ) - val primaryColor = AccentPurple Canvas( modifier = Modifier .fillMaxWidth() @@ -485,14 +479,14 @@ fun ChatScreen( ) }, modifier = Modifier.testTag("suggestion_chip_$idx"), - shape = RoundedCornerShape(20.dp), + shape = CircleShape, border = SuggestionChipDefaults.suggestionChipBorder( enabled = true, - borderColor = AccentPurple.copy(alpha = 0.4f) + borderColor = MaterialTheme.colorScheme.outline ), colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = AccentPurple.copy(alpha = 0.08f), - labelColor = AccentPurple + containerColor = Color.Transparent, + labelColor = MaterialTheme.colorScheme.primary ) ) } @@ -507,13 +501,12 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - // Pill input with the mic living inside it — one visual unit + // Bordered pill input with the mic living inside it Surface( - modifier = Modifier - .weight(1f) - .shadow(6.dp, RoundedCornerShape(28.dp), spotColor = Color.Black.copy(alpha = 0.35f)), - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh + modifier = Modifier.weight(1f), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) ) { Row(verticalAlignment = Alignment.CenterVertically) { TextField( @@ -554,7 +547,7 @@ fun ChatScreen( .testTag("chat_mic_button"), enabled = inputEnabled, colors = IconButtonDefaults.iconButtonColors( - containerColor = if (isListening) AccentRed else Color.Transparent, + containerColor = if (isListening) EmberRed else Color.Transparent, contentColor = if (isListening) Color.White else MaterialTheme.colorScheme.onSurfaceVariant ) @@ -567,15 +560,14 @@ fun ChatScreen( } } } - // Gradient send button + // Solid ink send button Box( modifier = Modifier .size(52.dp) - .shadow(6.dp, CircleShape, spotColor = AccentPurple.copy(alpha = 0.5f)) .clip(CircleShape) .background( - if (inputEnabled) HushGradient - else SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh) + if (inputEnabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceContainerHigh ) ) { IconButton( @@ -589,7 +581,7 @@ fun ChatScreen( .testTag("chat_send_button"), enabled = inputEnabled, colors = IconButtonDefaults.iconButtonColors( - contentColor = Color.White, + contentColor = MaterialTheme.colorScheme.onPrimary, disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) ) { @@ -610,7 +602,7 @@ fun ChatScreen( @Composable private fun AiStatusBubble( modifier: Modifier = Modifier, - containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, + accent: Color? = null, content: @Composable () -> Unit ) { Box( @@ -618,11 +610,13 @@ private fun AiStatusBubble( contentAlignment = Alignment.CenterStart ) { Surface( - modifier = modifier - .fillMaxWidth(0.94f), - shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 6.dp), - color = containerColor, - tonalElevation = 1.dp + modifier = modifier.fillMaxWidth(0.94f), + shape = RoundedCornerShape(20.dp, 20.dp, 20.dp, 6.dp), + color = accent?.copy(alpha = 0.08f) ?: MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke( + 1.dp, + accent?.copy(alpha = 0.30f) ?: MaterialTheme.colorScheme.outline + ) ) { Box(modifier = Modifier.padding(14.dp)) { content() @@ -638,12 +632,11 @@ private fun ChatBubble( showTimestamp: Boolean = true ) { val isUser = message.role == ChatRole.USER - val isDark = isSystemInDarkTheme() val bubbleShape = RoundedCornerShape( - topStart = if (isUser) 18.dp else 6.dp, - topEnd = if (isUser) 6.dp else 18.dp, - bottomStart = 18.dp, - bottomEnd = 18.dp + topStart = if (isUser) 20.dp else 6.dp, + topEnd = if (isUser) 6.dp else 20.dp, + bottomStart = 20.dp, + bottomEnd = 20.dp ) Box( modifier = Modifier.fillMaxWidth(), @@ -651,19 +644,22 @@ private fun ChatBubble( ) { Row(verticalAlignment = Alignment.Top) { if (!isUser) { + // Serif "h" monogram — the quiet voice of the app Box( modifier = Modifier .padding(top = 2.dp) .size(28.dp) .clip(CircleShape) - .background(HushGradient), + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)), contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Outlined.NotificationsOff, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(15.dp) + Text( + text = "h", + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = HushSerif, + fontStyle = FontStyle.Italic + ), + color = MaterialTheme.colorScheme.primary ) } Spacer(modifier = Modifier.width(8.dp)) @@ -672,33 +668,26 @@ private fun ChatBubble( horizontalAlignment = if (isUser) Alignment.End else Alignment.Start ) { if (isUser) { - // Gradient user bubble — the brand moment of the chat + // Solid ink bubble — the user's voice carries the brand color Box( modifier = Modifier .widthIn(max = 290.dp) - .shadow(4.dp, bubbleShape, spotColor = AccentPurple.copy(alpha = 0.4f)) .clip(bubbleShape) - .background(HushGradient) + .background(MaterialTheme.colorScheme.primary) ) { Text( text = message.text, modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp), - color = Color.White, + color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.bodyMedium ) } } else { Surface( - modifier = Modifier - .widthIn(max = 290.dp) - .then( - if (!isDark) Modifier.shadow( - elevation = 2.dp, - shape = bubbleShape - ) else Modifier - ), + modifier = Modifier.widthIn(max = 290.dp), shape = bubbleShape, - color = MaterialTheme.colorScheme.surfaceContainerHigh + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) ) { Text( text = message.text, @@ -730,9 +719,9 @@ private fun ProposedRuleCard( onCancel: () -> Unit ) { val actionColor = when (rule.action) { - RuleAction.BLOCK -> AccentRed - RuleAction.MUTE -> AccentAmber - RuleAction.ALLOW -> AccentGreen + RuleAction.BLOCK -> EmberRed + RuleAction.MUTE -> DuskGold + RuleAction.ALLOW -> SageGreen } val actionIcon = when (rule.action) { RuleAction.BLOCK -> Icons.Outlined.Block @@ -745,10 +734,9 @@ private fun ProposedRuleCard( modifier = Modifier .fillMaxWidth() .testTag("ai_rule_card"), - shape = RoundedCornerShape(20.dp), + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.5.dp, actionColor.copy(alpha = 0.5f)), - tonalElevation = 2.dp + border = BorderStroke(1.dp, actionColor.copy(alpha = 0.45f)) ) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -756,7 +744,7 @@ private fun ProposedRuleCard( modifier = Modifier .size(38.dp) .clip(RoundedCornerShape(12.dp)) - .background(actionColor.copy(alpha = 0.15f)), + .background(actionColor.copy(alpha = 0.14f)), contentAlignment = Alignment.Center ) { Icon( @@ -776,7 +764,7 @@ private fun ProposedRuleCard( ) Text( rule.summary, - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleMedium.copy(fontFamily = HushSerif), color = MaterialTheme.colorScheme.onSurface ) } @@ -790,13 +778,13 @@ private fun ProposedRuleCard( modifier = Modifier.fillMaxWidth() ) { AttributeChip(icon = actionIcon, label = rule.action.name.lowercase().replaceFirstChar { it.uppercase() }, color = actionColor) - AttributeChip(icon = Icons.Outlined.Apps, label = appLabel ?: "All apps", color = AccentBlue) + AttributeChip(icon = Icons.Outlined.Apps, label = appLabel ?: "All apps", color = SlateBlue) if (rule.timeStart != null || rule.timeEnd != null) { val window = listOfNotNull( rule.timeStart?.format(timeFormatter), rule.timeEnd?.format(timeFormatter) ).joinToString("–") - AttributeChip(icon = Icons.Outlined.Schedule, label = window, color = AccentTeal) + AttributeChip(icon = Icons.Outlined.Schedule, label = window, color = HarborTeal) } } if (rule.matchPattern != null || rule.isInverted) { @@ -804,10 +792,10 @@ private fun ProposedRuleCard( Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { rule.matchPattern?.let { pattern -> val patternIcon = if (rule.matchField == MatchField.SENDER) Icons.Outlined.Person else Icons.Outlined.Search - AttributeChip(icon = patternIcon, label = "\"$pattern\"", color = AccentPurple) + AttributeChip(icon = patternIcon, label = "\"$pattern\"", color = PlumMist) } if (rule.isInverted) { - AttributeChip(icon = Icons.Outlined.SwapHoriz, label = "Exception rule", color = AccentAmber) + AttributeChip(icon = Icons.Outlined.SwapHoriz, label = "Exception rule", color = DuskGold) } } } @@ -821,13 +809,13 @@ private fun ProposedRuleCard( Icon( imageVector = Icons.Default.Warning, contentDescription = null, - tint = AccentRed, + tint = EmberRed, modifier = Modifier.size(15.dp) ) Spacer(modifier = Modifier.width(6.dp)) Text( "This app isn't installed on this device.", - color = AccentRed, + color = EmberRed, style = MaterialTheme.typography.bodySmall ) } @@ -851,7 +839,7 @@ private fun ProposedRuleCard( Button( onClick = onConfirm, modifier = Modifier.testTag("ai_rule_confirm"), - shape = RoundedCornerShape(20.dp), + shape = CircleShape, colors = ButtonDefaults.buttonColors( containerColor = actionColor, contentColor = Color.White @@ -872,9 +860,9 @@ private fun ThinkingBubble() { ) { Surface( modifier = Modifier.testTag("ai_thinking_bubble"), - shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 6.dp), + shape = RoundedCornerShape(20.dp, 20.dp, 20.dp, 6.dp), color = MaterialTheme.colorScheme.surfaceVariant, - tonalElevation = 1.dp + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) ) { Row( modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), @@ -902,7 +890,7 @@ private fun ThinkingBubble() { .size(10.dp) .offset(y = offsetY.dp) .clip(CircleShape) - .background(AccentPurple.copy(alpha = 0.7f)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)) ) } } diff --git a/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt b/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt index e718796..8e7be9a 100644 --- a/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/history/HistoryScreen.kt @@ -1,16 +1,17 @@ package com.hush.app.ui.screens.history +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.Inbox -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -20,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,7 +60,6 @@ fun HistoryScreen( HushHeader( title = "History", subtitle = "Everything Hush has filtered", - leadingIcon = Icons.Outlined.Inbox, trailing = { IconButton( onClick = { showClearDialog = true }, @@ -68,7 +69,7 @@ fun HistoryScreen( Icon( imageVector = Icons.Outlined.DeleteSweep, contentDescription = "Clear all history", - tint = if (historyLogs.isNotEmpty()) AccentRed + tint = if (historyLogs.isNotEmpty()) EmberRed else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } @@ -77,39 +78,46 @@ fun HistoryScreen( Column(modifier = Modifier.padding(horizontal = 16.dp)) { // ── Search Input ── - TextField( - value = searchQuery, - onValueChange = { viewModel.setSearchQuery(it) }, - placeholder = { Text("Search notifications…") }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + modifier = Modifier.fillMaxWidth() + ) { + TextField( + value = searchQuery, + onValueChange = { viewModel.setSearchQuery(it) }, + placeholder = { Text("Search notifications…") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier + .fillMaxWidth() + .testTag("history_search_input"), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary ) - }, - modifier = Modifier - .fillMaxWidth() - .testTag("history_search_input"), - singleLine = true, - shape = RoundedCornerShape(24.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent ) - ) + } Spacer(modifier = Modifier.height(10.dp)) // ── Filter Tabs (null filter == "All") ── val tabs = listOf( FilterTab("All", null, MaterialTheme.colorScheme.primary), - FilterTab("Blocked", RuleAction.BLOCK, StatusBlocked), - FilterTab("Muted", RuleAction.MUTE, StatusMuted), - FilterTab("Delivered", RuleAction.ALLOW, StatusDelivered) + FilterTab("Blocked", RuleAction.BLOCK, EmberRed), + FilterTab("Muted", RuleAction.MUTE, DuskGold), + FilterTab("Delivered", RuleAction.ALLOW, SageGreen) ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { tabs.forEach { tab -> @@ -126,15 +134,15 @@ fun HistoryScreen( fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal ) }, - shape = RoundedCornerShape(20.dp), + shape = CircleShape, colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = tab.color.copy(alpha = 0.15f), + selectedContainerColor = tab.color.copy(alpha = 0.14f), selectedLabelColor = tab.color ), border = FilterChipDefaults.filterChipBorder( enabled = true, selected = selected, - borderColor = MaterialTheme.colorScheme.outlineVariant, + borderColor = MaterialTheme.colorScheme.outline, selectedBorderColor = tab.color.copy(alpha = 0.4f), selectedBorderWidth = 1.dp ), @@ -200,10 +208,13 @@ fun HistoryScreen( LocalDate.now().minusDays(1) -> "Yesterday" else -> logDate.format(dayFormatter) } + // Serif italic day marker — an editorial dateline Text( text = label, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = HushSerif, + fontStyle = FontStyle.Italic + ), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding( start = 4.dp, @@ -229,7 +240,8 @@ fun HistoryScreen( val log = selectedLog!! AlertDialog( onDismissRequest = { selectedLog = null }, - shape = RoundedCornerShape(24.dp), + shape = MaterialTheme.shapes.extraLarge, + containerColor = MaterialTheme.colorScheme.surfaceVariant, title = { Column { Text( @@ -274,7 +286,8 @@ fun HistoryScreen( if (showClearDialog) { AlertDialog( onDismissRequest = { showClearDialog = false }, - shape = RoundedCornerShape(24.dp), + shape = MaterialTheme.shapes.extraLarge, + containerColor = MaterialTheme.colorScheme.surfaceVariant, title = { Text("Clear all history?") }, text = { Text("This will permanently delete all notification logs.") }, confirmButton = { @@ -282,7 +295,7 @@ fun HistoryScreen( viewModel.clearAll() showClearDialog = false }) { - Text("Clear", color = AccentRed, fontWeight = FontWeight.SemiBold) + Text("Clear", color = EmberRed, fontWeight = FontWeight.SemiBold) } }, dismissButton = { @@ -323,8 +336,8 @@ private fun HistoryEntryCard( ) { val iconColors = remember { listOf( - AccentPurple, AccentBlue, AccentGreen, - AccentRed, AccentAmber, AccentTeal + PlumMist, SlateBlue, SageGreen, + EmberRed, DuskGold, HarborTeal ) } val iconColor = remember(log.appName) { @@ -334,14 +347,13 @@ private fun HistoryEntryCard( log.appName.firstOrNull()?.uppercaseChar()?.toString() ?: "?" } - Card( + Surface( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick), - shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) ) { Row( modifier = Modifier @@ -350,17 +362,17 @@ private fun HistoryEntryCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - // ── App Icon Circle ── + // ── App Initial Disc (serif monogram) ── Box( modifier = Modifier .size(42.dp) .clip(CircleShape) - .background(iconColor.copy(alpha = 0.15f)), + .background(iconColor.copy(alpha = 0.14f)), contentAlignment = Alignment.Center ) { Text( text = initial, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy(fontFamily = HushSerif), fontWeight = FontWeight.Bold, color = iconColor ) @@ -412,23 +424,23 @@ private fun HistoryEntryCard( // ── Status Badge Pill ── @Composable private fun StatusBadge(action: RuleAction) { - val (label, textColor, bgColor) = when (action) { - RuleAction.ALLOW -> Triple("Delivered", StatusDelivered, StatusDeliveredBg) - RuleAction.MUTE -> Triple("Muted", StatusMuted, StatusMutedBg) - RuleAction.BLOCK -> Triple("Blocked", StatusBlocked, StatusBlockedBg) + val (label, color) = when (action) { + RuleAction.ALLOW -> "Delivered" to SageGreen + RuleAction.MUTE -> "Muted" to DuskGold + RuleAction.BLOCK -> "Blocked" to EmberRed } Box( modifier = Modifier .clip(RoundedCornerShape(6.dp)) - .background(bgColor) + .background(color.copy(alpha = 0.14f)) .padding(horizontal = 8.dp, vertical = 2.dp) ) { Text( text = label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, - color = textColor + color = color ) } } diff --git a/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt b/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt index a1eb477..1646585 100644 --- a/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/onboarding/OnboardingScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -32,15 +33,15 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import com.hush.app.ui.components.HushGradient -import com.hush.app.ui.theme.AccentGreen -import com.hush.app.ui.theme.AccentPurple +import com.hush.app.ui.components.DuskGradient +import com.hush.app.ui.theme.HushSerif +import com.hush.app.ui.theme.SageGreen @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -81,11 +82,14 @@ fun OnboardingScreen( if (showBatteryWarning) { AlertDialog( onDismissRequest = { showBatteryWarning = false }, + shape = MaterialTheme.shapes.extraLarge, + containerColor = MaterialTheme.colorScheme.surfaceVariant, title = { Text("Keep App Alive") }, text = { Text("Hush works best when exempted from battery restrictions.") }, confirmButton = { Button( onClick = { showBatteryWarning = false }, + shape = CircleShape, modifier = Modifier.testTag("onboarding_battery_warning_dismiss") ) { Text("Dismiss") @@ -198,6 +202,7 @@ fun ColumnScope.WelcomeStep(onNext: () -> Unit) { verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) ) { + // Dusk-sky hero — the one gradient moment in the whole app Box( modifier = Modifier .size(104.dp) @@ -205,15 +210,15 @@ fun ColumnScope.WelcomeStep(onNext: () -> Unit) { scaleX = heroScale scaleY = heroScale } - .clip(RoundedCornerShape(32.dp)) - .background(HushGradient), + .clip(CircleShape) + .background(DuskGradient), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Outlined.NotificationsOff, contentDescription = null, tint = Color.White, - modifier = Modifier.size(52.dp) + modifier = Modifier.size(48.dp) ) } Spacer(modifier = Modifier.height(28.dp)) @@ -225,7 +230,10 @@ fun ColumnScope.WelcomeStep(onNext: () -> Unit) { Spacer(modifier = Modifier.height(8.dp)) Text( text = "Control notifications by simply\ntalking to your phone", - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyLarge.copy( + fontFamily = HushSerif, + fontStyle = FontStyle.Italic + ), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -256,7 +264,7 @@ fun ColumnScope.WelcomeStep(onNext: () -> Unit) { .fillMaxWidth() .height(54.dp) .testTag("onboarding_next_button"), - shape = RoundedCornerShape(27.dp) + shape = CircleShape ) { Text("Get Started", style = MaterialTheme.typography.labelLarge) } @@ -273,13 +281,13 @@ private fun FeatureRow(icon: ImageVector, title: String, description: String) { modifier = Modifier .size(44.dp) .clip(RoundedCornerShape(14.dp)) - .background(AccentPurple.copy(alpha = 0.12f)), + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = null, - tint = AccentPurple, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp) ) } @@ -374,7 +382,7 @@ fun ColumnScope.PermissionsStep( .fillMaxWidth() .height(54.dp) .testTag("onboarding_next_button"), - shape = RoundedCornerShape(27.dp) + shape = CircleShape ) { Text("Continue", style = MaterialTheme.typography.labelLarge) } @@ -405,11 +413,13 @@ fun PermissionRow( buttonTag: String? = null, icon: ImageVector = Icons.Filled.Notifications ) { - Card( + Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke( + 1.dp, + if (isGranted) SageGreen.copy(alpha = 0.45f) else MaterialTheme.colorScheme.outline ) ) { Row( @@ -421,15 +431,15 @@ fun PermissionRow( .size(42.dp) .clip(RoundedCornerShape(13.dp)) .background( - if (isGranted) AccentGreen.copy(alpha = 0.14f) - else AccentPurple.copy(alpha = 0.12f) + if (isGranted) SageGreen.copy(alpha = 0.14f) + else MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) ), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = null, - tint = if (isGranted) AccentGreen else AccentPurple, + tint = if (isGranted) SageGreen else MaterialTheme.colorScheme.primary, modifier = Modifier.size(21.dp) ) } @@ -448,12 +458,12 @@ fun PermissionRow( Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "Granted", - tint = AccentGreen + tint = SageGreen ) } else { Button( onClick = onRequest, - shape = RoundedCornerShape(20.dp), + shape = CircleShape, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 8.dp), modifier = if (buttonTag != null) Modifier.testTag(buttonTag) else Modifier ) { @@ -475,13 +485,13 @@ fun ColumnScope.AICoreStep(onComplete: () -> Unit) { modifier = Modifier .size(104.dp) .clip(CircleShape) - .background(AccentGreen.copy(alpha = 0.14f)), + .background(SageGreen.copy(alpha = 0.14f)), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "Ready", - tint = AccentGreen, + tint = SageGreen, modifier = Modifier.size(56.dp) ) } @@ -505,7 +515,7 @@ fun ColumnScope.AICoreStep(onComplete: () -> Unit) { .fillMaxWidth() .height(54.dp) .testTag("onboarding_start_button"), - shape = RoundedCornerShape(27.dp) + shape = CircleShape ) { Text("Enter Hush", style = MaterialTheme.typography.labelLarge) } diff --git a/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt b/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt index 57e8ee7..aca30cb 100644 --- a/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/rules/RulesScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete @@ -39,9 +40,9 @@ import com.hush.app.ui.theme.* import java.time.format.DateTimeFormatter private fun actionColor(action: RuleAction): Color = when (action) { - RuleAction.BLOCK -> AccentRed - RuleAction.MUTE -> AccentAmber - RuleAction.ALLOW -> AccentGreen + RuleAction.BLOCK -> EmberRed + RuleAction.MUTE -> DuskGold + RuleAction.ALLOW -> SageGreen } private fun actionIcon(action: RuleAction): ImageVector = when (action) { @@ -71,8 +72,7 @@ fun RulesScreen( HushHeader( title = "Rules", subtitle = if (rulesList.isEmpty()) "Your filters live here" - else "${rulesList.count { it.enabled }} of ${rulesList.size} active", - leadingIcon = Icons.Outlined.FilterAlt + else "${rulesList.count { it.enabled }} of ${rulesList.size} active" ) // ── Content ── @@ -121,8 +121,8 @@ fun RulesScreen( Box( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(18.dp)) - .background(AccentRed) + .clip(MaterialTheme.shapes.large) + .background(EmberRed) .padding(horizontal = 20.dp), contentAlignment = Alignment.CenterEnd ) { @@ -154,7 +154,8 @@ fun RulesScreen( var actionState by remember(rule) { mutableStateOf(rule.action) } AlertDialog( onDismissRequest = { selectedRule = null }, - shape = RoundedCornerShape(24.dp), + shape = MaterialTheme.shapes.extraLarge, + containerColor = MaterialTheme.colorScheme.surfaceVariant, title = { Column { Text( @@ -213,8 +214,9 @@ fun RulesScreen( modifier = Modifier.size(16.dp) ) }, + shape = CircleShape, colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = color.copy(alpha = 0.15f), + selectedContainerColor = color.copy(alpha = 0.14f), selectedLabelColor = color, selectedLeadingIconColor = color ), @@ -229,10 +231,10 @@ fun RulesScreen( modifier = Modifier .fillMaxWidth() .testTag("rule_delete_button"), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, AccentRed.copy(alpha = 0.5f)), + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, EmberRed.copy(alpha = 0.5f)), colors = ButtonDefaults.outlinedButtonColors( - contentColor = AccentRed + contentColor = EmberRed ) ) { Icon( @@ -270,7 +272,8 @@ fun RulesScreen( val rule = rulePendingDeletion!! AlertDialog( onDismissRequest = { rulePendingDeletion = null }, - shape = RoundedCornerShape(24.dp), + shape = MaterialTheme.shapes.extraLarge, + containerColor = MaterialTheme.colorScheme.surfaceVariant, title = { Text("Delete rule?") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -295,7 +298,7 @@ fun RulesScreen( }, modifier = Modifier.testTag("rule_delete_confirm_button") ) { - Text("Delete", color = AccentRed, fontWeight = FontWeight.SemiBold) + Text("Delete", color = EmberRed, fontWeight = FontWeight.SemiBold) } }, dismissButton = { @@ -318,37 +321,30 @@ private fun RuleCard( val color = actionColor(rule.action) val contentAlpha = if (rule.enabled) 1f else 0.55f - Card( + Surface( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .testTag("rule_card_${rule.id}"), - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) ) { Row( modifier = Modifier .fillMaxWidth() + .height(IntrinsicSize.Min) .padding(14.dp), verticalAlignment = Alignment.CenterVertically ) { - // Action-colored icon tile + // Action-colored edge bar — a quiet signal of what the rule does Box( modifier = Modifier - .size(44.dp) - .clip(RoundedCornerShape(14.dp)) - .background(color.copy(alpha = if (rule.enabled) 0.15f else 0.08f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = actionIcon(rule.action), - contentDescription = null, - tint = color.copy(alpha = contentAlpha), - modifier = Modifier.size(22.dp) - ) - } + .width(4.dp) + .fillMaxHeight() + .clip(CircleShape) + .background(color.copy(alpha = if (rule.enabled) 0.9f else 0.35f)) + ) Spacer(modifier = Modifier.width(12.dp)) // Rule info @@ -361,10 +357,15 @@ private fun RuleCard( ) Spacer(modifier = Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AttributeChip( + icon = actionIcon(rule.action), + label = rule.action.name.lowercase().replaceFirstChar { it.uppercase() }, + color = color.copy(alpha = contentAlpha) + ) AttributeChip( icon = Icons.Outlined.Apps, label = rule.appDisplayName ?: rule.appPackage ?: "All apps", - color = AccentBlue.copy(alpha = contentAlpha) + color = SlateBlue.copy(alpha = contentAlpha) ) if (rule.timeStart != null || rule.timeEnd != null) { AttributeChip( @@ -373,21 +374,21 @@ private fun RuleCard( rule.timeStart?.format(timeFormatter), rule.timeEnd?.format(timeFormatter) ).joinToString("–"), - color = AccentTeal.copy(alpha = contentAlpha) + color = HarborTeal.copy(alpha = contentAlpha) ) } if (rule.isInverted) { AttributeChip( icon = Icons.Outlined.SwapHoriz, label = "Exception", - color = AccentAmber.copy(alpha = contentAlpha) + color = DuskGold.copy(alpha = contentAlpha) ) } else if (rule.matchPattern != null) { val patternIcon = if (rule.matchField == MatchField.SENDER) Icons.Outlined.Person else Icons.Outlined.Search AttributeChip( icon = patternIcon, label = "\"${rule.matchPattern}\"", - color = AccentPurple.copy(alpha = contentAlpha) + color = PlumMist.copy(alpha = contentAlpha) ) } } diff --git a/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt index 719cb91..95542f6 100644 --- a/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/hush/app/ui/screens/settings/SettingsScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight @@ -14,10 +15,8 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -29,13 +28,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.compose.runtime.collectAsState import com.hush.app.domain.model.RuleAction +import com.hush.app.ui.components.HushHeader +import com.hush.app.ui.components.QuietSurface import com.hush.app.ui.theme.* @OptIn(ExperimentalMaterial3Api::class) @@ -78,7 +79,8 @@ fun SettingsScreen( var showRetentionMenu by remember { mutableStateOf(false) } Scaffold( - modifier = modifier.testTag("settings_screen") + modifier = modifier.testTag("settings_screen"), + containerColor = MaterialTheme.colorScheme.background ) { innerPadding -> Column( modifier = Modifier @@ -86,400 +88,402 @@ fun SettingsScreen( .padding(innerPadding) .verticalScroll(rememberScrollState()) ) { - com.hush.app.ui.components.HushHeader( + HushHeader( title = "Settings", - subtitle = "Service status & preferences", - leadingIcon = Icons.Outlined.Settings + subtitle = "Service status & preferences" ) // ── Section: Service Status ── SectionLabel("SERVICE STATUS") - - // Notification Interception - SettingsRow( - icon = Icons.Filled.Notifications, - iconTint = Color.White, - iconBackground = AccentGreen, - title = "Notification Interception", - subtitle = "Intercept and classify incoming notifications", - trailing = { - StatusBadge( - isActive = isNotificationActive, - modifier = Modifier.testTag("settings_notification_status") - ) - } - ) - SettingsDivider() - - // Voice Input - SettingsRow( - icon = Icons.Filled.Mic, - iconTint = Color.White, - iconBackground = AccentGreen, - title = "Voice Input", - subtitle = "Control Hush with voice commands", - trailing = { - StatusBadge( - isActive = isVoiceActive, - modifier = Modifier.testTag("settings_voice_status") - ) - } - ) + SettingsGroup { + SettingsRow( + icon = Icons.Filled.Notifications, + accent = SageGreen, + title = "Notification Interception", + subtitle = "Intercept and classify incoming notifications", + trailing = { + StatusBadge( + isActive = isNotificationActive, + modifier = Modifier.testTag("settings_notification_status") + ) + } + ) + SettingsDivider() + SettingsRow( + icon = Icons.Filled.Mic, + accent = SlateBlue, + title = "Voice Input", + subtitle = "Control Hush with voice commands", + trailing = { + StatusBadge( + isActive = isVoiceActive, + modifier = Modifier.testTag("settings_voice_status") + ) + } + ) + } Spacer(modifier = Modifier.height(24.dp)) // ── Section: Appearance ── SectionLabel("APPEARANCE") - - SettingsRow( - icon = Icons.Filled.Palette, - iconTint = Color.White, - iconBackground = AccentPurple, - title = "Theme", - subtitle = "Choose light, dark, or system theme", - onClick = { showThemeMenu = !showThemeMenu }, - modifier = Modifier.testTag("settings_theme_pref"), - trailing = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = themeOption, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + SettingsGroup { + SettingsRow( + icon = Icons.Filled.Palette, + accent = PlumMist, + title = "Theme", + subtitle = "Choose light, dark, or system theme", + onClick = { showThemeMenu = !showThemeMenu }, + modifier = Modifier.testTag("settings_theme_pref"), + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = themeOption, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + + // Theme selector (inline options) + if (showThemeMenu) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 72.dp, end = 16.dp, top = 4.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OptionChip( + label = "Light Theme", + isSelected = themeOption == "Light Theme", + onClick = { + themeOption = "Light Theme" + prefs.edit().putString("theme_option", "Light Theme").apply() + showThemeMenu = false + }, + modifier = Modifier.testTag("settings_theme_light_option") ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + OptionChip( + label = "Dark Theme", + isSelected = themeOption == "Dark Theme", + onClick = { + themeOption = "Dark Theme" + prefs.edit().putString("theme_option", "Dark Theme").apply() + showThemeMenu = false + }, + modifier = Modifier.testTag("settings_theme_dark_option") + ) + OptionChip( + label = "System Default", + isSelected = themeOption == "System Default", + onClick = { + themeOption = "System Default" + prefs.edit().putString("theme_option", "System Default").apply() + showThemeMenu = false + }, + modifier = Modifier.testTag("settings_theme_system_option") ) } } - ) - - // Theme selector (inline options) - if (showThemeMenu) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 80.dp, end = 24.dp, top = 4.dp, bottom = 4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - ThemeOptionChip( - label = "Light Theme", - isSelected = themeOption == "Light Theme", - onClick = { - themeOption = "Light Theme" - prefs.edit().putString("theme_option", "Light Theme").apply() - showThemeMenu = false - }, - modifier = Modifier.testTag("settings_theme_light_option") - ) - ThemeOptionChip( - label = "Dark Theme", - isSelected = themeOption == "Dark Theme", - onClick = { - themeOption = "Dark Theme" - prefs.edit().putString("theme_option", "Dark Theme").apply() - showThemeMenu = false - }, - modifier = Modifier.testTag("settings_theme_dark_option") - ) - ThemeOptionChip( - label = "System Default", - isSelected = themeOption == "System Default", - onClick = { - themeOption = "System Default" - prefs.edit().putString("theme_option", "System Default").apply() - showThemeMenu = false - }, - modifier = Modifier.testTag("settings_theme_system_option") - ) - } } Spacer(modifier = Modifier.height(24.dp)) // ── Section: Data ── SectionLabel("DATA") - - SettingsRow( - icon = Icons.Filled.AutoDelete, - iconTint = Color.White, - iconBackground = AccentBlue, - title = "History Retention", - subtitle = "How long to keep notification history", - onClick = { showRetentionMenu = !showRetentionMenu }, - modifier = Modifier.testTag("settings_retention_pref"), - trailing = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = retentionPolicy, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + SettingsGroup { + SettingsRow( + icon = Icons.Filled.AutoDelete, + accent = SlateBlue, + title = "History Retention", + subtitle = "How long to keep notification history", + onClick = { showRetentionMenu = !showRetentionMenu }, + modifier = Modifier.testTag("settings_retention_pref"), + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = retentionPolicy, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + + // Retention selector (inline options) + if (showRetentionMenu) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 72.dp, end = 16.dp, top = 4.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OptionChip( + label = "7 Days", + isSelected = retentionPolicy == "7 Days", + onClick = { + retentionPolicy = "7 Days" + prefs.edit().putString("retention_policy", "7 Days").apply() + showRetentionMenu = false + viewModel.pruneDatabase("7 Days") + }, + modifier = Modifier.testTag("settings_retention_7_days") + ) + OptionChip( + label = "30 Days", + isSelected = retentionPolicy == "30 Days", + onClick = { + retentionPolicy = "30 Days" + prefs.edit().putString("retention_policy", "30 Days").apply() + showRetentionMenu = false + viewModel.pruneDatabase("30 Days") + }, + modifier = Modifier.testTag("settings_retention_30_days") ) - Spacer(modifier = Modifier.width(4.dp)) + OptionChip( + label = "90 Days", + isSelected = retentionPolicy == "90 Days", + onClick = { + retentionPolicy = "90 Days" + prefs.edit().putString("retention_policy", "90 Days").apply() + showRetentionMenu = false + viewModel.pruneDatabase("90 Days") + }, + modifier = Modifier.testTag("settings_retention_90_days") + ) + } + } + + SettingsDivider() + + // Rule Tester + var showRuleTester by remember { mutableStateOf(false) } + var selectedAppIndex by remember { mutableIntStateOf(-1) } + var testTitle by remember { mutableStateOf("") } + var testText by remember { mutableStateOf("") } + var testSender by remember { mutableStateOf("") } + var showAppDropdown by remember { mutableStateOf(false) } + val installedApps by viewModel.installedApps.collectAsState() + val testResult by viewModel.testResult.collectAsState() + + SettingsRow( + icon = Icons.Filled.PlayArrow, + accent = HarborTeal, + title = "Rule Tester", + subtitle = "Simulate a notification to test your rules", + onClick = { + showRuleTester = !showRuleTester + if (!showRuleTester) viewModel.clearTestResult() + }, + modifier = Modifier.testTag("settings_rule_tester"), + trailing = { Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + imageVector = if (showRuleTester) + Icons.Filled.KeyboardArrowDown + else + Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - ) - - // Retention selector (inline options) - if (showRetentionMenu) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 80.dp, end = 24.dp, top = 4.dp, bottom = 4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - ThemeOptionChip( - label = "7 Days", - isSelected = retentionPolicy == "7 Days", - onClick = { - retentionPolicy = "7 Days" - prefs.edit().putString("retention_policy", "7 Days").apply() - showRetentionMenu = false - viewModel.pruneDatabase("7 Days") - }, - modifier = Modifier.testTag("settings_retention_7_days") - ) - ThemeOptionChip( - label = "30 Days", - isSelected = retentionPolicy == "30 Days", - onClick = { - retentionPolicy = "30 Days" - prefs.edit().putString("retention_policy", "30 Days").apply() - showRetentionMenu = false - viewModel.pruneDatabase("30 Days") - }, - modifier = Modifier.testTag("settings_retention_30_days") - ) - ThemeOptionChip( - label = "90 Days", - isSelected = retentionPolicy == "90 Days", - onClick = { - retentionPolicy = "90 Days" - prefs.edit().putString("retention_policy", "90 Days").apply() - showRetentionMenu = false - viewModel.pruneDatabase("90 Days") - }, - modifier = Modifier.testTag("settings_retention_90_days") - ) - } - } + ) + + if (showRuleTester) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // App selector + Box { + OutlinedTextField( + value = if (selectedAppIndex >= 0 && selectedAppIndex < installedApps.size) + installedApps[selectedAppIndex].displayName + else "", + onValueChange = {}, + readOnly = true, + label = { Text("App") }, + placeholder = { Text("Select an app...") }, + shape = MaterialTheme.shapes.small, + modifier = Modifier + .fillMaxWidth() + .clickable { showAppDropdown = true }, + enabled = false, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = MaterialTheme.colorScheme.outline, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + // Invisible clickable overlay + Box( + modifier = Modifier + .matchParentSize() + .clickable { showAppDropdown = true } + ) + DropdownMenu( + expanded = showAppDropdown, + onDismissRequest = { showAppDropdown = false }, + modifier = Modifier.heightIn(max = 300.dp) + ) { + installedApps.forEachIndexed { index, app -> + DropdownMenuItem( + text = { + Column { + Text( + text = app.displayName, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + selectedAppIndex = index + showAppDropdown = false + } + ) + } + } + } - SettingsDivider() - - // Rule Tester - var showRuleTester by remember { mutableStateOf(false) } - var selectedAppIndex by remember { mutableIntStateOf(-1) } - var testTitle by remember { mutableStateOf("") } - var testText by remember { mutableStateOf("") } - var testSender by remember { mutableStateOf("") } - var showAppDropdown by remember { mutableStateOf(false) } - val installedApps by viewModel.installedApps.collectAsState() - val testResult by viewModel.testResult.collectAsState() - - SettingsRow( - icon = Icons.Filled.PlayArrow, - iconTint = Color.White, - iconBackground = AccentTeal, - title = "Rule Tester", - subtitle = "Simulate a notification to test your rules", - onClick = { - showRuleTester = !showRuleTester - if (!showRuleTester) viewModel.clearTestResult() - }, - modifier = Modifier.testTag("settings_rule_tester"), - trailing = { - Icon( - imageVector = if (showRuleTester) - Icons.Filled.KeyboardArrowDown - else - Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) + // Title field + OutlinedTextField( + value = testTitle, + onValueChange = { testTitle = it }, + label = { Text("Title") }, + placeholder = { Text("e.g. Promotion Alert") }, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) - if (showRuleTester) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // App selector - Box { + // Text field OutlinedTextField( - value = if (selectedAppIndex >= 0 && selectedAppIndex < installedApps.size) - installedApps[selectedAppIndex].displayName - else "", - onValueChange = {}, - readOnly = true, - label = { Text("App") }, - placeholder = { Text("Select an app...") }, - modifier = Modifier - .fillMaxWidth() - .clickable { showAppDropdown = true }, - enabled = false, - colors = OutlinedTextFieldDefaults.colors( - disabledTextColor = MaterialTheme.colorScheme.onSurface, - disabledBorderColor = MaterialTheme.colorScheme.outline, - disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant - ) + value = testText, + onValueChange = { testText = it }, + label = { Text("Text / Body") }, + placeholder = { Text("e.g. 50% off today only!") }, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + singleLine = true ) - // Invisible clickable overlay - Box( - modifier = Modifier - .matchParentSize() - .clickable { showAppDropdown = true } + + // Sender field + OutlinedTextField( + value = testSender, + onValueChange = { testSender = it }, + label = { Text("Sender") }, + placeholder = { Text("e.g. Mom, Bob") }, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + singleLine = true ) - DropdownMenu( - expanded = showAppDropdown, - onDismissRequest = { showAppDropdown = false }, - modifier = Modifier.heightIn(max = 300.dp) + + // Evaluate button + Button( + onClick = { + if (selectedAppIndex >= 0 && selectedAppIndex < installedApps.size) { + val app = installedApps[selectedAppIndex] + viewModel.testRule( + packageName = app.packageName, + appName = app.displayName, + title = testTitle, + text = testText, + sender = testSender + ) + } + }, + enabled = selectedAppIndex >= 0, + modifier = Modifier.fillMaxWidth(), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) ) { - installedApps.forEachIndexed { index, app -> - DropdownMenuItem( - text = { - Column { - Text( - text = app.displayName, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = app.packageName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - onClick = { - selectedAppIndex = index - showAppDropdown = false - } - ) - } + Text("Evaluate Rules", fontWeight = FontWeight.SemiBold) } - } - // Title field - OutlinedTextField( - value = testTitle, - onValueChange = { testTitle = it }, - label = { Text("Title") }, - placeholder = { Text("e.g. Promotion Alert") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - // Text field - OutlinedTextField( - value = testText, - onValueChange = { testText = it }, - label = { Text("Text / Body") }, - placeholder = { Text("e.g. 50% off today only!") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - // Sender field - OutlinedTextField( - value = testSender, - onValueChange = { testSender = it }, - label = { Text("Sender") }, - placeholder = { Text("e.g. Mom, Bob") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - // Evaluate button - Button( - onClick = { - if (selectedAppIndex >= 0 && selectedAppIndex < installedApps.size) { - val app = installedApps[selectedAppIndex] - viewModel.testRule( - packageName = app.packageName, - appName = app.displayName, - title = testTitle, - text = testText, - sender = testSender - ) + // Result display + testResult?.let { result -> + val resultColor = when (result.action) { + RuleAction.BLOCK -> EmberRed + RuleAction.MUTE -> DuskGold + RuleAction.ALLOW -> SageGreen } - }, - enabled = selectedAppIndex >= 0, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = AccentTeal - ) - ) { - Text("Evaluate Rules", fontWeight = FontWeight.SemiBold) - } - - // Result display - testResult?.let { result -> - val (resultColor, resultBgColor) = when (result.action) { - RuleAction.BLOCK -> AccentRed to AccentRedLight - RuleAction.MUTE -> Color(0xFFD97706) to Color(0xFFFEF3C7) - RuleAction.ALLOW -> AccentGreen to AccentGreenLight - } - Surface( - shape = MaterialTheme.shapes.medium, - color = resultBgColor, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + Surface( + shape = MaterialTheme.shapes.medium, + color = resultColor.copy(alpha = 0.10f), + modifier = Modifier.fillMaxWidth() ) { - Text( - text = "Verdict: ${result.action.name}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = resultColor - ) - Text( - text = "A notification from ${result.appName} with these properties would be ${result.action.name.lowercase()}ed.", - style = MaterialTheme.typography.bodySmall, - color = resultColor.copy(alpha = 0.8f) - ) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Verdict: ${result.action.name}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = resultColor + ) + Text( + text = "A notification from ${result.appName} with these properties would be ${result.action.name.lowercase()}ed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } } + SettingsDivider() + + // Reset Onboarding + SettingsRow( + icon = Icons.Filled.RestartAlt, + accent = EmberRed, + title = "Reset Onboarding", + subtitle = "Re-run the first-time setup wizard", + onClick = onResetOnboarding, + trailing = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) } - SettingsDivider() - - // Reset Onboarding - SettingsRow( - icon = Icons.Filled.RestartAlt, - iconTint = Color.White, - iconBackground = AccentRed, - title = "Reset Onboarding", - subtitle = "Re-run the first-time setup wizard", - onClick = onResetOnboarding, - trailing = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) // ── Version Footer ── Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(32.dp)) Text( text = "Hush v1.0", - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = HushSerif, + fontStyle = FontStyle.Italic + ), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .fillMaxWidth() @@ -499,16 +503,26 @@ private fun SectionLabel(text: String) { style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - letterSpacing = MaterialTheme.typography.labelSmall.letterSpacing + modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp) ) } +/** A hairline-bordered island grouping related settings rows. */ +@Composable +private fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) { + QuietSurface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Column(content = content) + } +} + @Composable private fun SettingsRow( icon: ImageVector, - iconTint: Color, - iconBackground: Color, + accent: Color, title: String, subtitle: String, modifier: Modifier = Modifier, @@ -519,26 +533,26 @@ private fun SettingsRow( modifier = modifier .fillMaxWidth() .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(horizontal = 24.dp, vertical = 16.dp), + .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - // Colored circular icon + // Soft-washed icon tile Box( modifier = Modifier .size(40.dp) - .clip(CircleShape) - .background(iconBackground), + .clip(RoundedCornerShape(12.dp)) + .background(accent.copy(alpha = 0.14f)), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = null, - tint = iconTint, + tint = accent, modifier = Modifier.size(20.dp) ) } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(14.dp)) // Title + subtitle Column(modifier = Modifier.weight(1f)) { @@ -564,12 +578,11 @@ private fun SettingsRow( @Composable private fun StatusBadge(isActive: Boolean, modifier: Modifier = Modifier) { - val bgColor = if (isActive) AccentGreenLight else AccentRedLight - val textColor = if (isActive) AccentGreen else AccentRed + val color = if (isActive) SageGreen else EmberRed val label = if (isActive) "Active" else "Inactive" Surface( shape = CircleShape, - color = bgColor, + color = color.copy(alpha = 0.12f), // Read the badge as a single element (also lets tests query its text) modifier = modifier.semantics(mergeDescendants = true) {} ) { @@ -577,7 +590,7 @@ private fun StatusBadge(isActive: Boolean, modifier: Modifier = Modifier) { text = label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, - color = textColor, + color = color, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) ) } @@ -586,30 +599,32 @@ private fun StatusBadge(isActive: Boolean, modifier: Modifier = Modifier) { @Composable private fun SettingsDivider() { HorizontalDivider( - modifier = Modifier.padding(start = 80.dp, end = 24.dp), - thickness = 0.5.dp, + modifier = Modifier.padding(start = 70.dp, end = 16.dp), + thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant ) } @Composable -private fun ThemeOptionChip( +private fun OptionChip( label: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { + val primary = MaterialTheme.colorScheme.primary Surface( onClick = onClick, shape = MaterialTheme.shapes.small, - color = if (isSelected) AccentPurpleLight else MaterialTheme.colorScheme.surfaceVariant, + color = if (isSelected) primary.copy(alpha = 0.10f) + else MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier.fillMaxWidth() ) { Text( text = label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = if (isSelected) AccentPurple else MaterialTheme.colorScheme.onSurface, + color = if (isSelected) primary else MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) ) } diff --git a/app/src/main/java/com/hush/app/ui/theme/Color.kt b/app/src/main/java/com/hush/app/ui/theme/Color.kt index ad7121e..d71cc3d 100644 --- a/app/src/main/java/com/hush/app/ui/theme/Color.kt +++ b/app/src/main/java/com/hush/app/ui/theme/Color.kt @@ -2,90 +2,65 @@ package com.hush.app.ui.theme import androidx.compose.ui.graphics.Color -// ── Brand ── -val BrandViolet = Color(0xFF6C4EE3) -val BrandVioletBright = Color(0xFF7C5CFC) +// ═════════════════════════════════════════════════════════════════════ +// "Nocturne" palette — the moment after the noise stops. +// Light theme reads like paper at dawn; dark theme like a night sky. +// ═════════════════════════════════════════════════════════════════════ -// ── Light Palette (warm, paper-like) ── -val WarmCream = Color(0xFFFAF8F4) -val WarmSurface = Color(0xFFF5F2ED) -val WarmCard = Color(0xFFFFFFFF) -val WarmOnSurface = Color(0xFF1C1B1F) -val WarmOnSurfaceVariant = Color(0xFF6B6A6E) -val WarmOutline = Color(0xFFDDD9D1) +// ── Brand ── +val InkIndigo = Color(0xFF3E3663) // deep dusk indigo — primary (light) +val MoonLilac = Color(0xFFC7BAF4) // moonlit lilac — primary (dark) -// Light tonal surface containers (M3) -val LightContainerLowest = Color(0xFFFFFFFF) -val LightContainerLow = Color(0xFFF6F3EE) -val LightContainer = Color(0xFFF1EDE7) -val LightContainerHigh = Color(0xFFEBE7E0) -val LightContainerHighest = Color(0xFFE5E1D9) +// ── Dawn (light) surfaces ── +val DawnLinen = Color(0xFFF5F2EB) +val DawnCard = Color(0xFFFDFCF8) +val DawnInk = Color(0xFF232030) +val DawnInkMuted = Color(0xFF6F6A7B) +val DawnHairline = Color(0xFFE2DDD2) -// ── Dark Palette (deep violet-black) ── -val DarkBackground = Color(0xFF131118) -val DarkSurface = Color(0xFF131118) -val DarkCard = Color(0xFF211D29) -val DarkOnSurface = Color(0xFFE7E1EA) -val DarkOnSurfaceVariant = Color(0xFFA29DA8) -val DarkOutline = Color(0xFF3A3542) +val DawnContainerLowest = Color(0xFFFFFFFF) +val DawnContainerLow = Color(0xFFEFECE3) +val DawnContainer = Color(0xFFEAE6DC) +val DawnContainerHigh = Color(0xFFE3DFD3) +val DawnContainerHighest = Color(0xFFDCD7CA) -// Dark tonal surface containers (M3) -val DarkContainerLowest = Color(0xFF0E0C12) -val DarkContainerLow = Color(0xFF1A1721) -val DarkContainer = Color(0xFF1F1B27) -val DarkContainerHigh = Color(0xFF29242F) -val DarkContainerHighest = Color(0xFF342E3D) +// ── Midnight (dark) surfaces ── +val MidnightSky = Color(0xFF121019) +val MidnightCard = Color(0xFF1C1926) +val MidnightInk = Color(0xFFECE8F4) +val MidnightInkMuted = Color(0xFFA39EB1) +val MidnightHairline = Color(0xFF353040) -// ── Accent Colors (for rule cards) ── -val AccentPurple = Color(0xFF7C5CFC) -val AccentPurpleLight = Color(0xFFF0EBFF) -val AccentBlue = Color(0xFF3B82F6) -val AccentBlueLight = Color(0xFFEBF2FF) -val AccentGreen = Color(0xFF22C55E) -val AccentGreenLight = Color(0xFFECFDF5) -val AccentRed = Color(0xFFEF4444) -val AccentRedLight = Color(0xFFFEF2F2) -val AccentAmber = Color(0xFFF59E0B) -val AccentAmberLight = Color(0xFFFFFBEB) -val AccentTeal = Color(0xFF14B8A6) -val AccentTealLight = Color(0xFFF0FDFA) +val MidnightContainerLowest = Color(0xFF0C0A11) +val MidnightContainerLow = Color(0xFF181521) +val MidnightContainer = Color(0xFF1D1A28) +val MidnightContainerHigh = Color(0xFF272232) +val MidnightContainerHighest = Color(0xFF312B3F) -// ── Status Badge Colors ── -val StatusDelivered = Color(0xFF22C55E) -val StatusDeliveredBg = Color(0xFFDCFCE7) -val StatusMuted = Color(0xFF8B5CF6) -val StatusMutedBg = Color(0xFFF3EEFF) -val StatusBatched = Color(0xFFF59E0B) -val StatusBatchedBg = Color(0xFFFEF9C3) -val StatusBlocked = Color(0xFFEF4444) -val StatusBlockedBg = Color(0xFFFEE2E2) -val StatusAllowed = Color(0xFF3B82F6) -val StatusAllowedBg = Color(0xFFDBEAFE) +// ── Semantic action colors (dusty, never neon) ── +val EmberRed = Color(0xFFD9645C) // Block +val DuskGold = Color(0xFFB98A2A) // Mute +val SageGreen = Color(0xFF5E9B67) // Allow / delivered / granted +val SlateBlue = Color(0xFF6480C4) // informational (apps, downloads) +val PlumMist = Color(0xFF9384CD) // match patterns / AI accents +val HarborTeal = Color(0xFF4F9490) // time windows -// ── Primary for interactions ── -val PrimaryLight = BrandViolet +// ── Primary roles ── +val PrimaryLight = InkIndigo val OnPrimaryLight = Color(0xFFFFFFFF) -val PrimaryContainerLight = Color(0xFFE9E1FF) -val OnPrimaryContainerLight = Color(0xFF22005D) +val PrimaryContainerLight = Color(0xFFE6E0F6) +val OnPrimaryContainerLight = Color(0xFF29224A) -val PrimaryDark = Color(0xFFCDBDFF) -val OnPrimaryDark = Color(0xFF32206B) -val PrimaryContainerDark = Color(0xFF4C3A94) -val OnPrimaryContainerDark = Color(0xFFE9E1FF) +val PrimaryDark = MoonLilac +val OnPrimaryDark = Color(0xFF2E2653) +val PrimaryContainerDark = Color(0xFF463D75) +val OnPrimaryContainerDark = Color(0xFFE6E0F6) // ── Error ── -val ErrorLight = Color(0xFFBA1A1A) -val ErrorContainerLight = Color(0xFFFDE8E8) -val OnErrorContainerLight = Color(0xFF410002) - -val ErrorDark = Color(0xFFFFB4AB) -val ErrorContainerDark = Color(0xFF93000A) -val OnErrorContainerDark = Color(0xFFFFDAD6) - -// ── Bottom Nav ── -val NavIndicator = Color(0xFFE9E1FF) -val NavIndicatorDark = Color(0xFF4C3A94) +val ErrorLight = Color(0xFFB3261E) +val ErrorContainerLight = Color(0xFFF9DEDC) +val OnErrorContainerLight = Color(0xFF410E0B) -// ── Card-on-Light (for status cards that always have light backgrounds) ── -val CardOnLight = Color(0xFF1C1B1F) -val CardOnLightMuted = Color(0xFF5A5860) +val ErrorDark = Color(0xFFF2B8B5) +val ErrorContainerDark = Color(0xFF8C1D18) +val OnErrorContainerDark = Color(0xFFF9DEDC) diff --git a/app/src/main/java/com/hush/app/ui/theme/Theme.kt b/app/src/main/java/com/hush/app/ui/theme/Theme.kt index 7bcc4d2..881b151 100644 --- a/app/src/main/java/com/hush/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/hush/app/ui/theme/Theme.kt @@ -1,34 +1,37 @@ package com.hush.app.ui.theme +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.unit.dp private val LightColorScheme = lightColorScheme( primary = PrimaryLight, onPrimary = OnPrimaryLight, primaryContainer = PrimaryContainerLight, onPrimaryContainer = OnPrimaryContainerLight, - secondary = AccentPurple, + secondary = PlumMist, onSecondary = OnPrimaryLight, - secondaryContainer = NavIndicator, + secondaryContainer = PrimaryContainerLight, onSecondaryContainer = OnPrimaryContainerLight, - tertiary = AccentTeal, - background = WarmCream, - surface = WarmCream, - surfaceVariant = WarmCard, - surfaceContainerLowest = LightContainerLowest, - surfaceContainerLow = LightContainerLow, - surfaceContainer = LightContainer, - surfaceContainerHigh = LightContainerHigh, - surfaceContainerHighest = LightContainerHighest, - onBackground = WarmOnSurface, - onSurface = WarmOnSurface, - onSurfaceVariant = WarmOnSurfaceVariant, - outline = WarmOutline, - outlineVariant = WarmOutline, + tertiary = HarborTeal, + background = DawnLinen, + surface = DawnLinen, + surfaceVariant = DawnCard, + surfaceContainerLowest = DawnContainerLowest, + surfaceContainerLow = DawnContainerLow, + surfaceContainer = DawnContainer, + surfaceContainerHigh = DawnContainerHigh, + surfaceContainerHighest = DawnContainerHighest, + onBackground = DawnInk, + onSurface = DawnInk, + onSurfaceVariant = DawnInkMuted, + outline = DawnHairline, + outlineVariant = DawnHairline, error = ErrorLight, errorContainer = ErrorContainerLight, onErrorContainer = OnErrorContainerLight @@ -39,29 +42,38 @@ private val DarkColorScheme = darkColorScheme( onPrimary = OnPrimaryDark, primaryContainer = PrimaryContainerDark, onPrimaryContainer = OnPrimaryContainerDark, - secondary = AccentPurple, - onSecondary = OnPrimaryLight, - secondaryContainer = NavIndicatorDark, + secondary = PlumMist, + onSecondary = OnPrimaryDark, + secondaryContainer = PrimaryContainerDark, onSecondaryContainer = OnPrimaryContainerDark, - tertiary = AccentTeal, - background = DarkBackground, - surface = DarkSurface, - surfaceVariant = DarkCard, - surfaceContainerLowest = DarkContainerLowest, - surfaceContainerLow = DarkContainerLow, - surfaceContainer = DarkContainer, - surfaceContainerHigh = DarkContainerHigh, - surfaceContainerHighest = DarkContainerHighest, - onBackground = DarkOnSurface, - onSurface = DarkOnSurface, - onSurfaceVariant = DarkOnSurfaceVariant, - outline = DarkOutline, - outlineVariant = DarkOutline, + tertiary = HarborTeal, + background = MidnightSky, + surface = MidnightSky, + surfaceVariant = MidnightCard, + surfaceContainerLowest = MidnightContainerLowest, + surfaceContainerLow = MidnightContainerLow, + surfaceContainer = MidnightContainer, + surfaceContainerHigh = MidnightContainerHigh, + surfaceContainerHighest = MidnightContainerHighest, + onBackground = MidnightInk, + onSurface = MidnightInk, + onSurfaceVariant = MidnightInkMuted, + outline = MidnightHairline, + outlineVariant = MidnightHairline, error = ErrorDark, errorContainer = ErrorContainerDark, onErrorContainer = OnErrorContainerDark ) +/** Soft, generous corner language — everything sits like a river stone. */ +private val HushShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(22.dp), + extraLarge = RoundedCornerShape(28.dp) +) + @Composable fun HushTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -72,6 +84,7 @@ fun HushTheme( MaterialTheme( colorScheme = colorScheme, typography = Typography, + shapes = HushShapes, content = content ) } diff --git a/app/src/main/java/com/hush/app/ui/theme/Type.kt b/app/src/main/java/com/hush/app/ui/theme/Type.kt index 1ffa5e6..b1c1e4a 100644 --- a/app/src/main/java/com/hush/app/ui/theme/Type.kt +++ b/app/src/main/java/com/hush/app/ui/theme/Type.kt @@ -6,47 +6,54 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +/** + * Nocturne type system: editorial serif (system Noto Serif) for display, + * headline, and large-title moments; the default sans for everything the + * user reads at speed. The contrast between the two is the identity. + */ +val HushSerif = FontFamily.Serif + val Typography = Typography( displayLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 34.sp, - lineHeight = 40.sp, + fontFamily = HushSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 42.sp, + lineHeight = 48.sp, letterSpacing = (-0.5).sp ), displayMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 28.sp, - lineHeight = 34.sp, + fontFamily = HushSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 38.sp, letterSpacing = (-0.25).sp ), headlineLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 26.sp, - lineHeight = 32.sp, + fontFamily = HushSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 34.sp, letterSpacing = (-0.25).sp ), headlineMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - lineHeight = 30.sp, + fontFamily = HushSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, + lineHeight = 32.sp, letterSpacing = (-0.25).sp ), headlineSmall = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = HushSerif, fontWeight = FontWeight.SemiBold, - fontSize = 20.sp, - lineHeight = 26.sp, + fontSize = 22.sp, + lineHeight = 28.sp, letterSpacing = 0.sp ), titleLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = HushSerif, fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp, + fontSize = 21.sp, + lineHeight = 27.sp, letterSpacing = 0.sp ), titleMedium = TextStyle( @@ -103,6 +110,6 @@ val Typography = Typography( fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 15.sp, - letterSpacing = 0.4.sp + letterSpacing = 0.8.sp ) )