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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-19 23:06:29 +09:00
parent cc383eb668
commit d7dddb3cf9
14 changed files with 474 additions and 162 deletions

View file

@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PicoClaw" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.PicoClaw" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#FF050510</item>
</style>
</resources>

View file

@ -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)

View file

@ -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
)
}

View file

@ -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
)
)

View file

@ -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)

View file

@ -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
)
}
}

View file

@ -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
)
}
}

View file

@ -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),
),
)
}

View file

@ -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"
)
}
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}
)

View file

@ -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,13 +63,48 @@ fun SettingsScreen(
) {
val uiState by viewModel.uiState.collectAsState()
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(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(
painter = painterResource(LucideR.drawable.lucide_ic_arrow_left),
contentDescription = "Back",
tint = TextSecondary
)
}
}
)
@ -67,7 +117,11 @@ fun SettingsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text("Text-to-Speech", style = MaterialTheme.typography.titleMedium)
Text(
"Text-to-Speech",
style = MaterialTheme.typography.titleMedium,
color = NeonCyan
)
EngineSelector(
selectedEngine = uiState.ttsConfig.enginePackageName,
@ -97,12 +151,17 @@ fun SettingsScreen(
Button(
onClick = viewModel::onTestSpeak,
enabled = !uiState.isTesting
enabled = !uiState.isTesting,
colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan,
contentColor = DeepBlack
)
) {
Text(if (uiState.isTesting) "Speaking..." else "Test Voice")
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@ -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
)
)
}
}

View file

@ -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"
}

View file

@ -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(