From d7dddb3cf96200929ec76ff2363956a660afe173 Mon Sep 17 00:00:00 2001 From: Kohei Date: Thu, 19 Feb 2026 23:06:29 +0900 Subject: [PATCH] feat: redesign UI with dark glassmorphism theme and Lucide icons Replace the default Material Design theme with a futuristic dark glassmorphism aesthetic featuring ambient gradient backgrounds, translucent glass-effect surfaces, neon cyan/purple accents, and Lucide icon pack for consistent thin-line iconography. Co-Authored-By: Claude Opus 4.6 --- android/app/src/main/res/values/themes.xml | 4 +- .../picoclaw/android/core/ui/theme/Color.kt | 43 +++- .../picoclaw/android/core/ui/theme/Theme.kt | 57 ++--- .../io/picoclaw/android/core/ui/theme/Type.kt | 49 +++++ android/feature/chat/build.gradle.kts | 1 + .../chat/component/ConnectionBanner.kt | 22 +- .../feature/chat/component/ImagePreview.kt | 21 +- .../feature/chat/component/MessageBubble.kt | 21 +- .../feature/chat/component/MessageInput.kt | 77 +++++-- .../feature/chat/component/StatusIndicator.kt | 6 +- .../android/feature/chat/screen/ChatScreen.kt | 65 +++++- .../feature/chat/screen/SettingsScreen.kt | 200 ++++++++++++------ .../feature/chat/voice/VoiceModeOverlay.kt | 66 ++++-- .../android/feature/chat/voice/VoiceOrb.kt | 4 +- 14 files changed, 474 insertions(+), 162 deletions(-) diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 92c14c2ad..e390a99af 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,4 +1,6 @@ - diff --git a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Color.kt b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Color.kt index 7686b0b89..c542acdc7 100644 --- a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Color.kt +++ b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Color.kt @@ -2,16 +2,37 @@ package io.picoclaw.android.core.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +// ─── Base Palette ───────────────────────────────────────────── +val DeepBlack = Color(0xFF050510) +val DarkSurface = Color(0xFF0F0F23) +val DarkCard = Color(0xFF141428) -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) +// ─── Accent Colors ──────────────────────────────────────────── +val NeonCyan = Color(0xFF00D4FF) +val ElectricPurple = Color(0xFFA855F7) +val AccentPink = Color(0xFFEC4899) -val UserBubble = Color(0xFF2196F3) -val AgentBubble = Color(0xFF424242) -val ConnectedGreen = Color(0xFF4CAF50) -val ReconnectingYellow = Color(0xFFFFC107) -val DisconnectedRed = Color(0xFFF44336) +// ─── Glassmorphism ──────────────────────────────────────────── +val GlassWhite = Color(0x14FFFFFF) +val GlassBorder = Color(0x1FFFFFFF) + +// ─── Message Bubbles ────────────────────────────────────────── +val UserBubble = Color(0x1A00D4FF) +val UserBubbleBorder = Color(0x3300D4FF) +val AgentBubble = Color(0x14A855F7) +val AgentBubbleBorder = Color(0x26A855F7) + +// ─── Text Colors ────────────────────────────────────────────── +val TextPrimary = Color(0xFFF0F0FF) +val TextSecondary = Color(0xB3F0F0FF) +val TextTertiary = Color(0x66F0F0FF) + +// ─── Status Colors ──────────────────────────────────────────── +val ConnectedGreen = Color(0xFF22C55E) +val ReconnectingYellow = Color(0xFFFBBF24) +val DisconnectedRed = Color(0xFFEF4444) + +// ─── Ambient Gradient Orbs ──────────────────────────────────── +val GradientCyan = Color(0xFF06B6D4) +val GradientPurple = Color(0xFF7C3AED) +val GradientPink = Color(0xFFDB2777) diff --git a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Theme.kt b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Theme.kt index cb4cbf198..e0fb58320 100644 --- a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Theme.kt +++ b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Theme.kt @@ -1,45 +1,50 @@ package io.picoclaw.android.core.ui.theme -import android.os.Build -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.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 +private val FuturisticDarkScheme = darkColorScheme( + primary = NeonCyan, + onPrimary = DeepBlack, + primaryContainer = NeonCyan.copy(alpha = 0.15f), + onPrimaryContainer = NeonCyan, + secondary = ElectricPurple, + onSecondary = DeepBlack, + secondaryContainer = ElectricPurple.copy(alpha = 0.15f), + onSecondaryContainer = ElectricPurple, + tertiary = AccentPink, + onTertiary = DeepBlack, + background = DeepBlack, + onBackground = TextPrimary, + surface = DarkSurface, + onSurface = TextPrimary, + surfaceVariant = DarkCard, + onSurfaceVariant = TextSecondary, + outline = GlassBorder, + outlineVariant = GlassWhite, + error = DisconnectedRed, + onError = TextPrimary, ) -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 +private val FuturisticShapes = Shapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(28.dp), ) @Composable fun PicoClawTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - MaterialTheme( - colorScheme = colorScheme, + colorScheme = FuturisticDarkScheme, typography = Typography, + shapes = FuturisticShapes, content = content ) } diff --git a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Type.kt b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Type.kt index 1d024a221..c926c4620 100644 --- a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Type.kt +++ b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Type.kt @@ -7,11 +7,60 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp val Typography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Light, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = 1.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.8.sp + ), bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, + letterSpacing = 0.3.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, letterSpacing = 0.5.sp ) ) diff --git a/android/feature/chat/build.gradle.kts b/android/feature/chat/build.gradle.kts index 11c89e096..338f3b9c9 100644 --- a/android/feature/chat/build.gradle.kts +++ b/android/feature/chat/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) implementation(libs.compose.icons.extended) + implementation("com.composables:icons-lucide-android:2.2.1") implementation(libs.activity.compose) implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.viewmodel.compose) diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ConnectionBanner.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ConnectionBanner.kt index 9dad83930..bf5a8bb91 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ConnectionBanner.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ConnectionBanner.kt @@ -2,9 +2,11 @@ package io.picoclaw.android.feature.chat.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -22,7 +24,7 @@ fun ConnectionBanner( modifier: Modifier = Modifier ) { AnimatedVisibility(visible = connectionState != ConnectionState.CONNECTED) { - val (color, text) = when (connectionState) { + val (accentColor, text) = when (connectionState) { ConnectionState.CONNECTING -> ReconnectingYellow to "Connecting..." ConnectionState.RECONNECTING -> ReconnectingYellow to "Reconnecting..." ConnectionState.DISCONNECTED -> DisconnectedRed to "Disconnected" @@ -31,14 +33,24 @@ fun ConnectionBanner( Box( modifier = modifier .fillMaxWidth() - .background(color) - .padding(vertical = 4.dp), + .padding(horizontal = 16.dp, vertical = 4.dp) + .background( + color = accentColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + .border( + width = 0.5.dp, + color = accentColor.copy(alpha = 0.3f), + shape = RoundedCornerShape(12.dp) + ) + .padding(vertical = 8.dp), contentAlignment = Alignment.Center ) { Text( text = text, - color = Color.White, - fontSize = 12.sp + color = accentColor, + fontSize = 12.sp, + letterSpacing = 0.5.sp ) } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt index 974375239..dd3c13ec0 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt @@ -1,6 +1,7 @@ package io.picoclaw.android.feature.chat.component import android.net.Uri +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding @@ -8,8 +9,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -17,9 +16,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import io.picoclaw.android.core.domain.model.ImageAttachment +import io.picoclaw.android.core.ui.theme.GlassBorder +import io.picoclaw.android.core.ui.theme.TextSecondary +import com.composables.icons.lucide.R as LucideR @Composable fun ImagePreviewRow( @@ -30,7 +33,7 @@ fun ImagePreviewRow( if (images.isEmpty()) return LazyRow( - modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), + modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed(images) { index, attachment -> @@ -40,7 +43,12 @@ fun ImagePreviewRow( contentDescription = null, modifier = Modifier .size(64.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(RoundedCornerShape(12.dp)) + .border( + width = 0.5.dp, + color = GlassBorder, + shape = RoundedCornerShape(12.dp) + ), contentScale = ContentScale.Crop ) IconButton( @@ -50,9 +58,10 @@ fun ImagePreviewRow( .align(Alignment.TopEnd) ) { Icon( - Icons.Default.Close, + painter = painterResource(LucideR.drawable.lucide_ic_x), contentDescription = "Remove", - modifier = Modifier.size(14.dp) + modifier = Modifier.size(14.dp), + tint = TextSecondary ) } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt index 678ad335b..b35bad7f4 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt @@ -1,5 +1,6 @@ package io.picoclaw.android.feature.chat.component +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio @@ -13,7 +14,6 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -23,7 +23,10 @@ import com.mikepenz.markdown.m3.markdownTypography import io.picoclaw.android.core.domain.model.ChatMessage import io.picoclaw.android.core.domain.model.MessageSender import io.picoclaw.android.core.ui.theme.AgentBubble +import io.picoclaw.android.core.ui.theme.AgentBubbleBorder +import io.picoclaw.android.core.ui.theme.TextPrimary import io.picoclaw.android.core.ui.theme.UserBubble +import io.picoclaw.android.core.ui.theme.UserBubbleBorder import java.io.File @Composable @@ -34,22 +37,24 @@ fun MessageBubble( val isUser = message.sender == MessageSender.USER val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart val bubbleColor = if (isUser) UserBubble else AgentBubble + val borderColor = if (isUser) UserBubbleBorder else AgentBubbleBorder val shape = RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = if (isUser) 16.dp else 4.dp, - bottomEnd = if (isUser) 4.dp else 16.dp + topStart = 20.dp, + topEnd = 20.dp, + bottomStart = if (isUser) 20.dp else 4.dp, + bottomEnd = if (isUser) 4.dp else 20.dp ) Box( modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), + .padding(horizontal = 12.dp, vertical = 3.dp), contentAlignment = alignment ) { Surface( shape = shape, color = bubbleColor, + border = BorderStroke(0.5.dp, borderColor), modifier = Modifier.widthIn(max = 300.dp) ) { Column(modifier = Modifier.padding(12.dp)) { @@ -72,9 +77,9 @@ fun MessageBubble( if (message.content.isNotEmpty()) { Markdown( content = message.content, - colors = markdownColor(text = Color.White), + colors = markdownColor(text = TextPrimary), typography = markdownTypography( - paragraph = MaterialTheme.typography.bodyLarge.copy(color = Color.White), + paragraph = MaterialTheme.typography.bodyLarge.copy(color = TextPrimary), ), ) } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt index acb400588..c4f2fa35b 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt @@ -1,26 +1,35 @@ package io.picoclaw.android.feature.chat.component +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Mic +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import io.picoclaw.android.core.ui.theme.DeepBlack +import io.picoclaw.android.core.ui.theme.GlassBorder +import io.picoclaw.android.core.ui.theme.GlassWhite +import io.picoclaw.android.core.ui.theme.NeonCyan +import io.picoclaw.android.core.ui.theme.TextPrimary +import io.picoclaw.android.core.ui.theme.TextSecondary +import io.picoclaw.android.core.ui.theme.TextTertiary +import com.composables.icons.lucide.R as LucideR @Composable fun MessageInput( @@ -35,32 +44,70 @@ fun MessageInput( Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), + .padding(horizontal = 12.dp, vertical = 8.dp) + .background( + color = GlassWhite, + shape = RoundedCornerShape(28.dp) + ) + .border( + width = 0.5.dp, + color = GlassBorder, + shape = RoundedCornerShape(28.dp) + ) + .padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onCameraClick) { - Icon(Icons.Default.CameraAlt, contentDescription = "Camera") + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_camera), + contentDescription = "Camera", + tint = TextSecondary + ) } IconButton(onClick = onGalleryClick) { - Icon(Icons.Default.Image, contentDescription = "Gallery") + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_image), + contentDescription = "Gallery", + tint = TextSecondary + ) } IconButton(onClick = onMicClick) { - Icon(Icons.Default.Mic, contentDescription = "Voice") + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_mic), + contentDescription = "Voice", + tint = TextSecondary + ) } OutlinedTextField( value = text, onValueChange = onTextChanged, modifier = Modifier.weight(1f), - placeholder = { Text("Message...") }, + placeholder = { Text("Message...", color = TextTertiary) }, maxLines = 4, - shape = MaterialTheme.shapes.extraLarge + shape = RoundedCornerShape(24.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + cursorColor = NeonCyan, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(4.dp)) FilledIconButton( onClick = onSendClick, - shape = CircleShape + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = NeonCyan, + contentColor = DeepBlack + ) ) { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send") + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_send_horizontal), + contentDescription = "Send" + ) } } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt index 873bf481d..016eabfb4 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/StatusIndicator.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import io.picoclaw.android.core.ui.theme.NeonCyan +import io.picoclaw.android.core.ui.theme.TextSecondary @Composable fun StatusIndicator( @@ -36,13 +38,13 @@ fun StatusIndicator( CircularProgressIndicator( modifier = Modifier.size(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = NeonCyan ) Spacer(modifier = Modifier.width(8.dp)) Text( text = label ?: "", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = TextSecondary ) } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt index f8e9206be..171332ea3 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt @@ -7,19 +7,19 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -29,11 +29,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.core.content.FileProvider import io.picoclaw.android.core.domain.model.ImageAttachment +import io.picoclaw.android.core.ui.theme.DeepBlack +import io.picoclaw.android.core.ui.theme.GradientCyan +import io.picoclaw.android.core.ui.theme.GradientPink +import io.picoclaw.android.core.ui.theme.GradientPurple +import io.picoclaw.android.core.ui.theme.TextSecondary import io.picoclaw.android.feature.chat.ChatEvent import io.picoclaw.android.feature.chat.ChatViewModel +import com.composables.icons.lucide.R as LucideR import io.picoclaw.android.feature.chat.component.ConnectionBanner import io.picoclaw.android.feature.chat.component.ImagePreviewRow import io.picoclaw.android.feature.chat.component.MessageInput @@ -96,7 +107,6 @@ fun ChatScreen( } } - // RECORD_AUDIO permission var pendingVoiceStart by remember { mutableStateOf(false) } val micPermissionLauncher = rememberLauncherForActivityResult( @@ -119,7 +129,6 @@ fun ChatScreen( } } - // Back handler for voice mode BackHandler(enabled = uiState.voiceModeState.isActive) { viewModel.onEvent(ChatEvent.OnVoiceModeStop) } @@ -152,14 +161,58 @@ fun ChatScreen( } } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(DeepBlack) + .drawBehind { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientCyan.copy(alpha = 0.07f), + Color.Transparent + ), + center = Offset(size.width * 0.15f, size.height * 0.1f), + radius = size.width * 0.8f + ) + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientPurple.copy(alpha = 0.07f), + Color.Transparent + ), + center = Offset(size.width * 0.85f, size.height * 0.9f), + radius = size.width * 0.7f + ) + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientPink.copy(alpha = 0.03f), + Color.Transparent + ), + center = Offset(size.width * 0.5f, size.height * 0.5f), + radius = size.width * 0.5f + ) + ) + } + ) { Scaffold( + containerColor = Color.Transparent, topBar = { TopAppBar( title = { Text("PicoClaw") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), actions = { IconButton(onClick = onNavigateToSettings) { - Icon(Icons.Default.Settings, contentDescription = "Settings") + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_settings), + contentDescription = "Settings", + tint = TextSecondary + ) } } ) diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/SettingsScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/SettingsScreen.kt index c081a1b4b..98474d537 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/SettingsScreen.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/SettingsScreen.kt @@ -1,17 +1,15 @@ -// TTS settings are currently used only within the chat feature, so this screen -// is placed under feature/chat. If TTS settings become shared across multiple -// features in the future, consider extracting them into a dedicated feature/settings module. package io.picoclaw.android.feature.chat.screen +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType @@ -21,10 +19,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -34,9 +35,23 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import io.picoclaw.android.core.domain.model.TtsEngineInfo import io.picoclaw.android.core.domain.model.TtsVoiceInfo +import io.picoclaw.android.core.ui.theme.DeepBlack +import io.picoclaw.android.core.ui.theme.GlassBorder +import io.picoclaw.android.core.ui.theme.GlassWhite +import io.picoclaw.android.core.ui.theme.GradientCyan +import io.picoclaw.android.core.ui.theme.GradientPurple +import io.picoclaw.android.core.ui.theme.NeonCyan +import io.picoclaw.android.core.ui.theme.TextPrimary +import io.picoclaw.android.core.ui.theme.TextSecondary +import com.composables.icons.lucide.R as LucideR import io.picoclaw.android.feature.chat.SettingsViewModel import org.koin.androidx.compose.koinViewModel @@ -48,58 +63,102 @@ fun SettingsScreen( ) { val uiState by viewModel.uiState.collectAsState() - Scaffold( - topBar = { - TopAppBar( - title = { Text("Settings") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Box( + modifier = Modifier + .fillMaxSize() + .background(DeepBlack) + .drawBehind { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientCyan.copy(alpha = 0.07f), + Color.Transparent + ), + center = Offset(size.width * 0.15f, size.height * 0.1f), + radius = size.width * 0.8f + ) + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientPurple.copy(alpha = 0.07f), + Color.Transparent + ), + center = Offset(size.width * 0.85f, size.height * 0.9f), + radius = size.width * 0.7f + ) + ) + } + ) { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { Text("Settings") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_arrow_left), + contentDescription = "Back", + tint = TextSecondary + ) + } } - } - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Text("Text-to-Speech", style = MaterialTheme.typography.titleMedium) - - EngineSelector( - selectedEngine = uiState.ttsConfig.enginePackageName, - engines = uiState.availableEngines, - onEngineSelected = viewModel::onEngineSelected - ) - - VoiceSelector( - selectedVoiceName = uiState.ttsConfig.voiceName, - voices = uiState.availableVoices, - onVoiceSelected = viewModel::onVoiceSelected - ) - - SliderSetting( - label = "Speed", - value = uiState.ttsConfig.speechRate, - valueRange = 0.5f..2.0f, - onValueChangeFinished = viewModel::onSpeechRateChanged - ) - - SliderSetting( - label = "Pitch", - value = uiState.ttsConfig.pitch, - valueRange = 0.5f..2.0f, - onValueChangeFinished = viewModel::onPitchChanged - ) - - Button( - onClick = viewModel::onTestSpeak, - enabled = !uiState.isTesting + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) ) { - Text(if (uiState.isTesting) "Speaking..." else "Test Voice") + Text( + "Text-to-Speech", + style = MaterialTheme.typography.titleMedium, + color = NeonCyan + ) + + EngineSelector( + selectedEngine = uiState.ttsConfig.enginePackageName, + engines = uiState.availableEngines, + onEngineSelected = viewModel::onEngineSelected + ) + + VoiceSelector( + selectedVoiceName = uiState.ttsConfig.voiceName, + voices = uiState.availableVoices, + onVoiceSelected = viewModel::onVoiceSelected + ) + + SliderSetting( + label = "Speed", + value = uiState.ttsConfig.speechRate, + valueRange = 0.5f..2.0f, + onValueChangeFinished = viewModel::onSpeechRateChanged + ) + + SliderSetting( + label = "Pitch", + value = uiState.ttsConfig.pitch, + valueRange = 0.5f..2.0f, + onValueChangeFinished = viewModel::onPitchChanged + ) + + Button( + onClick = viewModel::onTestSpeak, + enabled = !uiState.isTesting, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack + ) + ) { + Text(if (uiState.isTesting) "Speaking..." else "Test Voice") + } } } } @@ -127,8 +186,16 @@ private fun EngineSelector( value = displayText, onValueChange = {}, readOnly = true, - label = { Text("Engine") }, + label = { Text("Engine", color = TextSecondary) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = NeonCyan.copy(alpha = 0.5f), + unfocusedBorderColor = GlassBorder, + focusedContainerColor = GlassWhite, + unfocusedContainerColor = Color.Transparent, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ), modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) .fillMaxWidth() @@ -179,8 +246,16 @@ private fun VoiceSelector( value = displayText, onValueChange = {}, readOnly = true, - label = { Text("Voice") }, + label = { Text("Voice", color = TextSecondary) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = NeonCyan.copy(alpha = 0.5f), + unfocusedBorderColor = GlassBorder, + focusedContainerColor = GlassWhite, + unfocusedContainerColor = Color.Transparent, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ), modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) .fillMaxWidth() @@ -224,11 +299,11 @@ private fun SliderSetting( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text(label, style = MaterialTheme.typography.bodyLarge) + Text(label, style = MaterialTheme.typography.bodyLarge, color = TextPrimary) Text( "%.1f".format(localValue), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + color = TextSecondary ) } Slider( @@ -236,7 +311,12 @@ private fun SliderSetting( onValueChange = { localValue = it }, onValueChangeFinished = { onValueChangeFinished(localValue) }, valueRange = valueRange, - steps = 14 + steps = 14, + colors = SliderDefaults.colors( + thumbColor = NeonCyan, + activeTrackColor = NeonCyan, + inactiveTrackColor = GlassWhite + ) ) } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt index f46d90124..7faf796bc 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt @@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -26,9 +24,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.picoclaw.android.core.domain.model.VoicePhase +import io.picoclaw.android.core.ui.theme.DeepBlack +import io.picoclaw.android.core.ui.theme.GradientCyan +import io.picoclaw.android.core.ui.theme.GradientPurple +import io.picoclaw.android.core.ui.theme.TextPrimary +import io.picoclaw.android.core.ui.theme.TextSecondary +import com.composables.icons.lucide.R as LucideR @Composable fun VoiceModeOverlay( @@ -45,9 +54,30 @@ fun VoiceModeOverlay( Box( modifier = modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) + .background(DeepBlack) + .drawBehind { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientCyan.copy(alpha = 0.1f), + Color.Transparent + ), + center = Offset(size.width * 0.3f, size.height * 0.3f), + radius = size.width * 0.6f + ) + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientPurple.copy(alpha = 0.1f), + Color.Transparent + ), + center = Offset(size.width * 0.7f, size.height * 0.7f), + radius = size.width * 0.5f + ) + ) + } ) { - // Close button IconButton( onClick = onClose, modifier = Modifier @@ -55,13 +85,13 @@ fun VoiceModeOverlay( .padding(16.dp) ) { Icon( - imageVector = Icons.Default.Close, - contentDescription = "閉じる", - modifier = Modifier.size(28.dp) + painter = painterResource(LucideR.drawable.lucide_ic_x), + contentDescription = "Close", + modifier = Modifier.size(28.dp), + tint = TextSecondary ) } - // Center content Column( modifier = Modifier .fillMaxSize() @@ -87,38 +117,34 @@ fun VoiceModeOverlay( Spacer(modifier = Modifier.height(32.dp)) - // Phase label Text( text = state.statusText.takeIf { state.phase == VoicePhase.THINKING } ?: phaseLabel(state.phase), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + color = TextSecondary ) Spacer(modifier = Modifier.height(24.dp)) - // Recognized text if (state.recognizedText.isNotEmpty()) { Text( text = state.recognizedText, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = TextPrimary, textAlign = TextAlign.Center ) } - // Response text if (state.responseText.isNotEmpty() && state.phase == VoicePhase.SPEAKING) { Spacer(modifier = Modifier.height(16.dp)) Text( text = state.responseText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + color = TextSecondary, textAlign = TextAlign.Center ) } - // Error message if (state.errorMessage != null) { Spacer(modifier = Modifier.height(16.dp)) Text( @@ -135,9 +161,9 @@ fun VoiceModeOverlay( private fun phaseLabel(phase: VoicePhase): String = when (phase) { VoicePhase.IDLE -> "" - VoicePhase.LISTENING -> "聞き取り中..." - VoicePhase.SENDING -> "送信中..." - VoicePhase.THINKING -> "考え中..." - VoicePhase.SPEAKING -> "話しています..." - VoicePhase.ERROR -> "エラー" + VoicePhase.LISTENING -> "Listening..." + VoicePhase.SENDING -> "Sending..." + VoicePhase.THINKING -> "Thinking..." + VoicePhase.SPEAKING -> "Speaking..." + VoicePhase.ERROR -> "Error" } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceOrb.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceOrb.kt index 3ec25fd7c..f2b9877f8 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceOrb.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceOrb.kt @@ -22,12 +22,12 @@ import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin -private val ListeningColor = Color(0xFF4A9EFF) +private val ListeningColor = Color(0xFF00D4FF) private val SendingColor = Color(0xFFFF8C42) private val ThinkingColor = Color(0xFFA855F7) private val SpeakingColor = Color(0xFF22C55E) private val ErrorColor = Color(0xFFEF4444) -private val IdleColor = Color(0xFF6B7280) +private val IdleColor = Color(0xFF4A5568) @Composable fun VoiceOrb(