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:
parent
cc383eb668
commit
d7dddb3cf9
14 changed files with 474 additions and 162 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,58 +63,102 @@ fun SettingsScreen(
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
Box(
|
||||||
topBar = {
|
modifier = Modifier
|
||||||
TopAppBar(
|
.fillMaxSize()
|
||||||
title = { Text("Settings") },
|
.background(DeepBlack)
|
||||||
navigationIcon = {
|
.drawBehind {
|
||||||
IconButton(onClick = onNavigateBack) {
|
drawCircle(
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
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 ->
|
||||||
) { padding ->
|
Column(
|
||||||
Column(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxSize()
|
||||||
.fillMaxSize()
|
.padding(padding)
|
||||||
.padding(padding)
|
.padding(16.dp),
|
||||||
.padding(16.dp),
|
verticalArrangement = Arrangement.spacedBy(24.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
|
|
||||||
) {
|
) {
|
||||||
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,
|
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
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue