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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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> </resources>

View file

@ -2,16 +2,37 @@ package io.picoclaw.android.core.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) // ─── Base Palette ─────────────────────────────────────────────
val PurpleGrey80 = Color(0xFFCCC2DC) val DeepBlack = Color(0xFF050510)
val Pink80 = Color(0xFFEFB8C8) val DarkSurface = Color(0xFF0F0F23)
val DarkCard = Color(0xFF141428)
val Purple40 = Color(0xFF6650a4) // ─── Accent Colors ────────────────────────────────────────────
val PurpleGrey40 = Color(0xFF625b71) val NeonCyan = Color(0xFF00D4FF)
val Pink40 = Color(0xFF7D5260) val ElectricPurple = Color(0xFFA855F7)
val AccentPink = Color(0xFFEC4899)
val UserBubble = Color(0xFF2196F3) // ─── Glassmorphism ────────────────────────────────────────────
val AgentBubble = Color(0xFF424242) val GlassWhite = Color(0x14FFFFFF)
val ConnectedGreen = Color(0xFF4CAF50) val GlassBorder = Color(0x1FFFFFFF)
val ReconnectingYellow = Color(0xFFFFC107)
val DisconnectedRed = Color(0xFFF44336) // ─── 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 package io.picoclaw.android.core.ui.theme
import android.os.Build import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme 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.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp
private val DarkColorScheme = darkColorScheme( private val FuturisticDarkScheme = darkColorScheme(
primary = Purple80, primary = NeonCyan,
secondary = PurpleGrey80, onPrimary = DeepBlack,
tertiary = Pink80 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( private val FuturisticShapes = Shapes(
primary = Purple40, small = RoundedCornerShape(8.dp),
secondary = PurpleGrey40, medium = RoundedCornerShape(16.dp),
tertiary = Pink40 large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(28.dp),
) )
@Composable @Composable
fun PicoClawTheme( fun PicoClawTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit 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( MaterialTheme(
colorScheme = colorScheme, colorScheme = FuturisticDarkScheme,
typography = Typography, typography = Typography,
shapes = FuturisticShapes,
content = content content = content
) )
} }

View file

@ -7,11 +7,60 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
val Typography = Typography( 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( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.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 letterSpacing = 0.5.sp
) )
) )

View file

@ -30,6 +30,7 @@ dependencies {
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.icons.extended) implementation(libs.compose.icons.extended)
implementation("com.composables:icons-lucide-android:2.2.1")
implementation(libs.activity.compose) implementation(libs.activity.compose)
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.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.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -22,7 +24,7 @@ fun ConnectionBanner(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
AnimatedVisibility(visible = connectionState != ConnectionState.CONNECTED) { AnimatedVisibility(visible = connectionState != ConnectionState.CONNECTED) {
val (color, text) = when (connectionState) { val (accentColor, text) = when (connectionState) {
ConnectionState.CONNECTING -> ReconnectingYellow to "Connecting..." ConnectionState.CONNECTING -> ReconnectingYellow to "Connecting..."
ConnectionState.RECONNECTING -> ReconnectingYellow to "Reconnecting..." ConnectionState.RECONNECTING -> ReconnectingYellow to "Reconnecting..."
ConnectionState.DISCONNECTED -> DisconnectedRed to "Disconnected" ConnectionState.DISCONNECTED -> DisconnectedRed to "Disconnected"
@ -31,14 +33,24 @@ fun ConnectionBanner(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(color) .padding(horizontal = 16.dp, vertical = 4.dp)
.padding(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 contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = text, text = text,
color = Color.White, color = accentColor,
fontSize = 12.sp fontSize = 12.sp,
letterSpacing = 0.5.sp
) )
} }
} }

View file

@ -1,6 +1,7 @@
package io.picoclaw.android.feature.chat.component package io.picoclaw.android.feature.chat.component
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding 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.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -17,9 +16,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.picoclaw.android.core.domain.model.ImageAttachment 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 @Composable
fun ImagePreviewRow( fun ImagePreviewRow(
@ -30,7 +33,7 @@ fun ImagePreviewRow(
if (images.isEmpty()) return if (images.isEmpty()) return
LazyRow( LazyRow(
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), modifier = modifier.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
itemsIndexed(images) { index, attachment -> itemsIndexed(images) { index, attachment ->
@ -40,7 +43,12 @@ fun ImagePreviewRow(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(64.dp) .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 contentScale = ContentScale.Crop
) )
IconButton( IconButton(
@ -50,9 +58,10 @@ fun ImagePreviewRow(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
) { ) {
Icon( Icon(
Icons.Default.Close, painter = painterResource(LucideR.drawable.lucide_ic_x),
contentDescription = "Remove", 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 package io.picoclaw.android.feature.chat.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@ -13,7 +14,6 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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.ChatMessage
import io.picoclaw.android.core.domain.model.MessageSender import io.picoclaw.android.core.domain.model.MessageSender
import io.picoclaw.android.core.ui.theme.AgentBubble 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.UserBubble
import io.picoclaw.android.core.ui.theme.UserBubbleBorder
import java.io.File import java.io.File
@Composable @Composable
@ -34,22 +37,24 @@ fun MessageBubble(
val isUser = message.sender == MessageSender.USER val isUser = message.sender == MessageSender.USER
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val bubbleColor = if (isUser) UserBubble else AgentBubble val bubbleColor = if (isUser) UserBubble else AgentBubble
val borderColor = if (isUser) UserBubbleBorder else AgentBubbleBorder
val shape = RoundedCornerShape( val shape = RoundedCornerShape(
topStart = 16.dp, topStart = 20.dp,
topEnd = 16.dp, topEnd = 20.dp,
bottomStart = if (isUser) 16.dp else 4.dp, bottomStart = if (isUser) 20.dp else 4.dp,
bottomEnd = if (isUser) 4.dp else 16.dp bottomEnd = if (isUser) 4.dp else 20.dp
) )
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(horizontal = 12.dp, vertical = 3.dp),
contentAlignment = alignment contentAlignment = alignment
) { ) {
Surface( Surface(
shape = shape, shape = shape,
color = bubbleColor, color = bubbleColor,
border = BorderStroke(0.5.dp, borderColor),
modifier = Modifier.widthIn(max = 300.dp) modifier = Modifier.widthIn(max = 300.dp)
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
@ -72,9 +77,9 @@ fun MessageBubble(
if (message.content.isNotEmpty()) { if (message.content.isNotEmpty()) {
Markdown( Markdown(
content = message.content, content = message.content,
colors = markdownColor(text = Color.White), colors = markdownColor(text = TextPrimary),
typography = markdownTypography( 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 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.foundation.shape.RoundedCornerShape
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.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp 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 @Composable
fun MessageInput( fun MessageInput(
@ -35,32 +44,70 @@ fun MessageInput(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .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 verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onCameraClick) { IconButton(onClick = onCameraClick) {
Icon(Icons.Default.CameraAlt, contentDescription = "Camera") Icon(
painter = painterResource(LucideR.drawable.lucide_ic_camera),
contentDescription = "Camera",
tint = TextSecondary
)
} }
IconButton(onClick = onGalleryClick) { IconButton(onClick = onGalleryClick) {
Icon(Icons.Default.Image, contentDescription = "Gallery") Icon(
painter = painterResource(LucideR.drawable.lucide_ic_image),
contentDescription = "Gallery",
tint = TextSecondary
)
} }
IconButton(onClick = onMicClick) { IconButton(onClick = onMicClick) {
Icon(Icons.Default.Mic, contentDescription = "Voice") Icon(
painter = painterResource(LucideR.drawable.lucide_ic_mic),
contentDescription = "Voice",
tint = TextSecondary
)
} }
OutlinedTextField( OutlinedTextField(
value = text, value = text,
onValueChange = onTextChanged, onValueChange = onTextChanged,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { Text("Message...") }, placeholder = { Text("Message...", color = TextTertiary) },
maxLines = 4, 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( FilledIconButton(
onClick = onSendClick, 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.ui.theme.NeonCyan
import io.picoclaw.android.core.ui.theme.TextSecondary
@Composable @Composable
fun StatusIndicator( fun StatusIndicator(
@ -36,13 +38,13 @@ fun StatusIndicator(
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = NeonCyan
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = label ?: "", text = label ?: "",
style = MaterialTheme.typography.bodySmall, 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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -29,11 +29,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import io.picoclaw.android.core.domain.model.ImageAttachment 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.ChatEvent
import io.picoclaw.android.feature.chat.ChatViewModel 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.ConnectionBanner
import io.picoclaw.android.feature.chat.component.ImagePreviewRow import io.picoclaw.android.feature.chat.component.ImagePreviewRow
import io.picoclaw.android.feature.chat.component.MessageInput import io.picoclaw.android.feature.chat.component.MessageInput
@ -96,7 +107,6 @@ fun ChatScreen(
} }
} }
// RECORD_AUDIO permission
var pendingVoiceStart by remember { mutableStateOf(false) } var pendingVoiceStart by remember { mutableStateOf(false) }
val micPermissionLauncher = rememberLauncherForActivityResult( val micPermissionLauncher = rememberLauncherForActivityResult(
@ -119,7 +129,6 @@ fun ChatScreen(
} }
} }
// Back handler for voice mode
BackHandler(enabled = uiState.voiceModeState.isActive) { BackHandler(enabled = uiState.voiceModeState.isActive) {
viewModel.onEvent(ChatEvent.OnVoiceModeStop) 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( Scaffold(
containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("PicoClaw") }, title = { Text("PicoClaw") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
actions = { actions = {
IconButton(onClick = onNavigateToSettings) { 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 package io.picoclaw.android.feature.chat.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuAnchorType
@ -21,10 +19,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -34,9 +35,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.TtsEngineInfo import io.picoclaw.android.core.domain.model.TtsEngineInfo
import io.picoclaw.android.core.domain.model.TtsVoiceInfo 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 io.picoclaw.android.feature.chat.SettingsViewModel
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@ -48,13 +63,48 @@ fun SettingsScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() 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( Scaffold(
containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Settings") }, title = { Text("Settings") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { 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), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.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( EngineSelector(
selectedEngine = uiState.ttsConfig.enginePackageName, selectedEngine = uiState.ttsConfig.enginePackageName,
@ -97,12 +151,17 @@ fun SettingsScreen(
Button( Button(
onClick = viewModel::onTestSpeak, onClick = viewModel::onTestSpeak,
enabled = !uiState.isTesting enabled = !uiState.isTesting,
colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan,
contentColor = DeepBlack
)
) { ) {
Text(if (uiState.isTesting) "Speaking..." else "Test Voice") Text(if (uiState.isTesting) "Speaking..." else "Test Voice")
} }
} }
} }
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -127,8 +186,16 @@ private fun EngineSelector(
value = displayText, value = displayText,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Engine") }, label = { Text("Engine", color = TextSecondary) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, 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 modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth() .fillMaxWidth()
@ -179,8 +246,16 @@ private fun VoiceSelector(
value = displayText, value = displayText,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Voice") }, label = { Text("Voice", color = TextSecondary) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, 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 modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth() .fillMaxWidth()
@ -224,11 +299,11 @@ private fun SliderSetting(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text(label, style = MaterialTheme.typography.bodyLarge) Text(label, style = MaterialTheme.typography.bodyLarge, color = TextPrimary)
Text( Text(
"%.1f".format(localValue), "%.1f".format(localValue),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) color = TextSecondary
) )
} }
Slider( Slider(
@ -236,7 +311,12 @@ private fun SliderSetting(
onValueChange = { localValue = it }, onValueChange = { localValue = it },
onValueChangeFinished = { onValueChangeFinished(localValue) }, onValueChangeFinished = { onValueChangeFinished(localValue) },
valueRange = valueRange, 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -26,9 +24,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.VoicePhase 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 @Composable
fun VoiceModeOverlay( fun VoiceModeOverlay(
@ -45,9 +54,30 @@ fun VoiceModeOverlay(
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .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( IconButton(
onClick = onClose, onClick = onClose,
modifier = Modifier modifier = Modifier
@ -55,13 +85,13 @@ fun VoiceModeOverlay(
.padding(16.dp) .padding(16.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, painter = painterResource(LucideR.drawable.lucide_ic_x),
contentDescription = "閉じる", contentDescription = "Close",
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp),
tint = TextSecondary
) )
} }
// Center content
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -87,38 +117,34 @@ fun VoiceModeOverlay(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Phase label
Text( Text(
text = state.statusText.takeIf { state.phase == VoicePhase.THINKING } text = state.statusText.takeIf { state.phase == VoicePhase.THINKING }
?: phaseLabel(state.phase), ?: phaseLabel(state.phase),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) color = TextSecondary
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Recognized text
if (state.recognizedText.isNotEmpty()) { if (state.recognizedText.isNotEmpty()) {
Text( Text(
text = state.recognizedText, text = state.recognizedText,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = TextPrimary,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
// Response text
if (state.responseText.isNotEmpty() && state.phase == VoicePhase.SPEAKING) { if (state.responseText.isNotEmpty() && state.phase == VoicePhase.SPEAKING) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = state.responseText, text = state.responseText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), color = TextSecondary,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
// Error message
if (state.errorMessage != null) { if (state.errorMessage != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
@ -135,9 +161,9 @@ fun VoiceModeOverlay(
private fun phaseLabel(phase: VoicePhase): String = when (phase) { private fun phaseLabel(phase: VoicePhase): String = when (phase) {
VoicePhase.IDLE -> "" VoicePhase.IDLE -> ""
VoicePhase.LISTENING -> "聞き取り中..." VoicePhase.LISTENING -> "Listening..."
VoicePhase.SENDING -> "送信中..." VoicePhase.SENDING -> "Sending..."
VoicePhase.THINKING -> "考え中..." VoicePhase.THINKING -> "Thinking..."
VoicePhase.SPEAKING -> "話しています..." VoicePhase.SPEAKING -> "Speaking..."
VoicePhase.ERROR -> "エラー" VoicePhase.ERROR -> "Error"
} }

View file

@ -22,12 +22,12 @@ import kotlin.math.PI
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
private val ListeningColor = Color(0xFF4A9EFF) private val ListeningColor = Color(0xFF00D4FF)
private val SendingColor = Color(0xFFFF8C42) private val SendingColor = Color(0xFFFF8C42)
private val ThinkingColor = Color(0xFFA855F7) private val ThinkingColor = Color(0xFFA855F7)
private val SpeakingColor = Color(0xFF22C55E) private val SpeakingColor = Color(0xFF22C55E)
private val ErrorColor = Color(0xFFEF4444) private val ErrorColor = Color(0xFFEF4444)
private val IdleColor = Color(0xFF6B7280) private val IdleColor = Color(0xFF4A5568)
@Composable @Composable
fun VoiceOrb( fun VoiceOrb(