Redesign hero to match web (suggestion pills, date/time, mode pill row, +/call/send input); add thinking phase with phase-aware haptics; glassier orb
This commit is contained in:
parent
d46949878d
commit
4cd117e703
7 changed files with 204 additions and 94 deletions
|
|
@ -43,6 +43,7 @@ dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
|
|
||||||
|
|
@ -106,14 +106,26 @@ fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = fals
|
||||||
CircleShape,
|
CircleShape,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Glass sphere
|
// Glass sphere — slightly translucent so the background shimmers through
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Brush.radialGradient(listOf(Color(0xFFF2C879), AmberDeep, Color(0xFF6E4A16))))
|
.background(
|
||||||
.border(1.dp, Brush.radialGradient(listOf(Color.White.copy(alpha = 0.5f), Color.Transparent)), CircleShape),
|
Brush.radialGradient(
|
||||||
|
listOf(
|
||||||
|
Color(0xFFFFE0A8).copy(alpha = 0.94f),
|
||||||
|
Amber.copy(alpha = 0.80f),
|
||||||
|
AmberDeep.copy(alpha = 0.90f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
1.5.dp,
|
||||||
|
Brush.radialGradient(listOf(Color.White.copy(alpha = 0.65f), Color.White.copy(alpha = 0.04f))),
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
// Specular highlight, top-left ("wet" reflection)
|
// Specular highlight, top-left ("wet" reflection)
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@ import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
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.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
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.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -31,6 +33,10 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Add
|
||||||
|
import androidx.compose.material.icons.rounded.Call
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -45,6 +51,7 @@ import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -52,7 +59,9 @@ import androidx.compose.ui.unit.sp
|
||||||
import dev.kaizen.app.ui.theme.Amber
|
import dev.kaizen.app.ui.theme.Amber
|
||||||
import dev.kaizen.app.ui.theme.AmberDeep
|
import dev.kaizen.app.ui.theme.AmberDeep
|
||||||
import dev.kaizen.app.ui.theme.OnAmber
|
import dev.kaizen.app.ui.theme.OnAmber
|
||||||
import java.time.LocalTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KaizenHeader() {
|
fun KaizenHeader() {
|
||||||
|
|
@ -70,55 +79,87 @@ fun KaizenHeader() {
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyHero(userName: String, onPick: (String) -> Unit) {
|
fun EmptyHero(userName: String, onPick: (String) -> Unit) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val hour = remember { LocalTime.now().hour }
|
val now = remember { LocalDateTime.now() }
|
||||||
// One grouped block (orb + greeting + tiles), scrollable so the keyboard never
|
val dateLine = remember {
|
||||||
// squeezes or clips it — only the input rises above the keyboard.
|
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
|
||||||
|
}
|
||||||
|
// One grouped, scrollable block so the keyboard never squeezes or clips it.
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(64.dp))
|
Spacer(Modifier.height(64.dp))
|
||||||
KaizenOrb(96.dp)
|
KaizenOrb(104.dp)
|
||||||
Spacer(Modifier.height(22.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text("${greeting(hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
|
Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text("Was liegt an?", color = cs.onBackground, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
Text("Was liegt an?", color = cs.onBackground, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.height(36.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp)
|
||||||
SuggestionTile(suggestions[0], Modifier.weight(1f)) { onPick(suggestions[0].prompt) }
|
Spacer(Modifier.height(32.dp))
|
||||||
SuggestionTile(suggestions[1], Modifier.weight(1f)) { onPick(suggestions[1].prompt) }
|
SuggestionPills(onPick)
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
SuggestionTile(suggestions[2], Modifier.weight(1f)) { onPick(suggestions[2].prompt) }
|
|
||||||
SuggestionTile(suggestions[3], Modifier.weight(1f)) { onPick(suggestions[3].prompt) }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
private fun SuggestionPills(onPick: (String) -> Unit) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
Column(
|
FlowRow(
|
||||||
modifier
|
Modifier.fillMaxWidth(),
|
||||||
.height(112.dp)
|
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||||
.clip(RoundedCornerShape(20.dp))
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
.background(cs.surface.copy(alpha = 0.72f))
|
|
||||||
.border(1.dp, cs.outline, RoundedCornerShape(20.dp))
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(horizontal = 16.dp, vertical = 15.dp),
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
) {
|
||||||
Box(
|
suggestions.forEach { suggestion ->
|
||||||
Modifier
|
Row(
|
||||||
.size(26.dp)
|
Modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(CircleShape)
|
||||||
.background(Brush.linearGradient(listOf(Amber, AmberDeep))),
|
.background(cs.surface.copy(alpha = 0.82f))
|
||||||
)
|
.border(1.dp, cs.outline, CircleShape)
|
||||||
Column {
|
.clickable { onPick(suggestion.prompt) }
|
||||||
Text(suggestion.title, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||||
Text(suggestion.subtitle, color = cs.onSurfaceVariant, fontSize = 12.sp)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(suggestion.icon, null, tint = cs.primary, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(suggestion.label, color = cs.onBackground, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ModePillsRow(selected: String, onSelect: (String) -> Unit) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 14.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
chatModes.forEach { mode ->
|
||||||
|
val active = mode.label == selected
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (active) cs.primary.copy(alpha = 0.16f) else cs.surface.copy(alpha = 0.7f))
|
||||||
|
.border(1.dp, if (active) cs.primary.copy(alpha = 0.40f) else cs.outline, CircleShape)
|
||||||
|
.clickable { onSelect(mode.label) }
|
||||||
|
.padding(horizontal = 13.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
mode.icon,
|
||||||
|
null,
|
||||||
|
tint = if (active) cs.primary else cs.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
mode.label,
|
||||||
|
color = if (active) cs.onBackground else cs.onSurfaceVariant,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +186,7 @@ fun MessageRow(message: Message) {
|
||||||
KaizenOrb(28.dp, streaming = message.streaming)
|
KaizenOrb(28.dp, streaming = message.streaming)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Box(Modifier.weight(1f).padding(top = 3.dp)) {
|
Box(Modifier.weight(1f).padding(top = 3.dp)) {
|
||||||
if (message.content.isEmpty() && message.streaming) {
|
if (message.thinking) {
|
||||||
TypingDots()
|
TypingDots()
|
||||||
} else {
|
} else {
|
||||||
val display = if (message.streaming) message.content + " ▌" else message.content
|
val display = if (message.streaming) message.content + " ▌" else message.content
|
||||||
|
|
@ -192,38 +233,43 @@ fun ChatInput(
|
||||||
) {
|
) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
Row(
|
Row(
|
||||||
modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
modifier
|
||||||
verticalAlignment = Alignment.Bottom,
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.background(cs.surfaceVariant.copy(alpha = 0.94f))
|
||||||
|
.border(1.dp, cs.outline, RoundedCornerShape(28.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Row(
|
GhostIconButton(Icons.Rounded.Add, "Anhang")
|
||||||
Modifier
|
Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp)) {
|
||||||
.weight(1f)
|
if (value.isEmpty()) {
|
||||||
.heightIn(min = 52.dp)
|
Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp)
|
||||||
.clip(RoundedCornerShape(26.dp))
|
|
||||||
.background(cs.surfaceVariant.copy(alpha = 0.94f))
|
|
||||||
.border(1.dp, cs.outline, RoundedCornerShape(26.dp))
|
|
||||||
.padding(horizontal = 18.dp, vertical = 15.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(Modifier.weight(1f)) {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
Text("Frag Kaizen …", color = cs.onSurfaceVariant, fontSize = 16.sp)
|
|
||||||
}
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
|
|
||||||
cursorBrush = SolidColor(Amber),
|
|
||||||
maxLines = 6,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
|
||||||
|
cursorBrush = SolidColor(Amber),
|
||||||
|
maxLines = 6,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(10.dp))
|
GhostIconButton(Icons.Rounded.Call, "Anruf")
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend)
|
SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
|
Box(Modifier.size(40.dp).clip(CircleShape), contentAlignment = Alignment.Center) {
|
||||||
|
Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
|
|
@ -233,11 +279,11 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
val fill = if (enabled) {
|
val fill = if (enabled) {
|
||||||
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
|
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
|
||||||
} else {
|
} else {
|
||||||
Modifier.background(cs.surfaceVariant)
|
Modifier.background(cs.surface)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.size(52.dp)
|
.size(44.dp)
|
||||||
.scale(pressScale)
|
.scale(pressScale)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.then(fill)
|
.then(fill)
|
||||||
|
|
@ -252,7 +298,7 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
/** Crisp upward arrow drawn with strokes (sharper than a glyph). */
|
/** Crisp upward arrow drawn with strokes (sharper than a glyph). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SendArrow(color: Color) {
|
private fun SendArrow(color: Color) {
|
||||||
Canvas(Modifier.size(20.dp)) {
|
Canvas(Modifier.size(18.dp)) {
|
||||||
val w = size.width
|
val w = size.width
|
||||||
val h = size.height
|
val h = size.height
|
||||||
val cx = w / 2f
|
val cx = w / 2f
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
package dev.kaizen.app.chat
|
package dev.kaizen.app.chat
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Adjust
|
||||||
|
import androidx.compose.material.icons.rounded.AutoAwesome
|
||||||
|
import androidx.compose.material.icons.rounded.Image
|
||||||
|
import androidx.compose.material.icons.rounded.Language
|
||||||
|
import androidx.compose.material.icons.rounded.Movie
|
||||||
|
import androidx.compose.material.icons.rounded.MusicNote
|
||||||
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
@ -12,15 +21,29 @@ data class Message(
|
||||||
val role: Role,
|
val role: Role,
|
||||||
val content: String,
|
val content: String,
|
||||||
val streaming: Boolean = false,
|
val streaming: Boolean = false,
|
||||||
|
val thinking: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Suggestion(val title: String, val subtitle: String, val prompt: String)
|
// Empty-state suggestion pills (mirrors the web hero)
|
||||||
|
data class Suggestion(val label: String, val prompt: String, val icon: ImageVector)
|
||||||
|
|
||||||
val suggestions = listOf(
|
val suggestions = listOf(
|
||||||
Suggestion("Brainstorm", "Ideen sammeln", "Lass uns zu einem Thema brainstormen."),
|
Suggestion("Brainstormen", "Lass uns zu einem Thema brainstormen.", Icons.Rounded.AutoAwesome),
|
||||||
Suggestion("Erklär mir", "einfach erklärt", "Erklär mir Quantenverschränkung ganz einfach."),
|
Suggestion("Bild generieren", "Generiere ein Bild von einem Bergsee bei Sonnenaufgang.", Icons.Rounded.Image),
|
||||||
Suggestion("Schreib", "Text & E-Mail", "Schreib eine kurze, freundliche E-Mail an mein Team."),
|
Suggestion("Video erstellen", "Erstelle ein kurzes Video von einer Stadt bei Nacht.", Icons.Rounded.Movie),
|
||||||
Suggestion("Code", "Programmieren", "Schreib eine Kotlin-Funktion, die Primzahlen findet."),
|
Suggestion("Web durchsuchen", "Durchsuche das Web nach den neuesten KI-Nachrichten.", Icons.Rounded.Language),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode pills above the input (visual only in Phase 0)
|
||||||
|
data class ChatMode(val label: String, val icon: ImageVector)
|
||||||
|
|
||||||
|
val chatModes = listOf(
|
||||||
|
ChatMode("Standard", Icons.Rounded.Adjust),
|
||||||
|
ChatMode("Sampling", Icons.Rounded.Tune),
|
||||||
|
ChatMode("Bild", Icons.Rounded.Image),
|
||||||
|
ChatMode("Suche", Icons.Rounded.Language),
|
||||||
|
ChatMode("Video", Icons.Rounded.Movie),
|
||||||
|
ChatMode("Audio", Icons.Rounded.MusicNote),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun greeting(hour: Int): String = when (hour) {
|
fun greeting(hour: Int): String = when (hour) {
|
||||||
|
|
@ -30,7 +53,10 @@ fun greeting(hour: Int): String = when (hour) {
|
||||||
else -> "Hallo"
|
else -> "Hallo"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Phase 0: faked stream so the feel is testable without a backend ---
|
// --- Phase 0: faked thinking + stream so the feel is testable without a backend ---
|
||||||
|
|
||||||
|
/** Variable "thinking" time before the answer streams — real models think for different durations. */
|
||||||
|
fun thinkingDurationMs(): Long = Random.nextLong(700L, 2600L)
|
||||||
|
|
||||||
private val cannedReplies = listOf(
|
private val cannedReplies = listOf(
|
||||||
"Klar, das kriegen wir hin. Lass uns das Schritt für Schritt durchgehen: " +
|
"Klar, das kriegen wir hin. Lass uns das Schritt für Schritt durchgehen: " +
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
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.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
|
@ -15,14 +17,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.kaizen.app.haptics.rememberHaptics
|
import dev.kaizen.app.haptics.rememberHaptics
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -34,6 +37,7 @@ fun ChatScreen(userName: String = "Bruno") {
|
||||||
var input by remember { mutableStateOf("") }
|
var input by remember { mutableStateOf("") }
|
||||||
var isStreaming by remember { mutableStateOf(false) }
|
var isStreaming by remember { mutableStateOf(false) }
|
||||||
var nextId by remember { mutableStateOf(0L) }
|
var nextId by remember { mutableStateOf(0L) }
|
||||||
|
var selectedMode by remember { mutableStateOf("Standard") }
|
||||||
|
|
||||||
fun send(text: String) {
|
fun send(text: String) {
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
|
|
@ -42,20 +46,26 @@ fun ChatScreen(userName: String = "Bruno") {
|
||||||
messages.add(Message(nextId++, Role.User, trimmed))
|
messages.add(Message(nextId++, Role.User, trimmed))
|
||||||
input = ""
|
input = ""
|
||||||
val assistantId = nextId++
|
val assistantId = nextId++
|
||||||
messages.add(Message(assistantId, Role.Assistant, "", streaming = true))
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true))
|
||||||
isStreaming = true
|
isStreaming = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
// Thinking phase — variable, like a real model
|
||||||
|
haptics.thinkingStart()
|
||||||
|
delay(thinkingDurationMs())
|
||||||
var first = true
|
var first = true
|
||||||
fakeReply(trimmed).collect { chunk ->
|
fakeReply(trimmed).collect { chunk ->
|
||||||
if (first) {
|
|
||||||
haptics.responseStart() // "the answer is arriving"
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
if (idx >= 0) messages[idx] = messages[idx].copy(content = messages[idx].content + chunk)
|
if (idx < 0) return@collect
|
||||||
|
messages[idx] = if (first) {
|
||||||
|
first = false
|
||||||
|
haptics.responseStart() // the answer begins
|
||||||
|
messages[idx].copy(thinking = false, content = chunk)
|
||||||
|
} else {
|
||||||
|
messages[idx].copy(content = messages[idx].content + chunk)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val idx = messages.indexOfLast { it.id == assistantId }
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false)
|
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
||||||
isStreaming = false
|
isStreaming = false
|
||||||
haptics.responseEnd()
|
haptics.responseEnd()
|
||||||
}
|
}
|
||||||
|
|
@ -84,13 +94,16 @@ fun ChatScreen(userName: String = "Bruno") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ChatInput(
|
Column(Modifier.navigationBarsPadding().imePadding()) {
|
||||||
value = input,
|
ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() })
|
||||||
onValueChange = { input = it },
|
Spacer(Modifier.height(10.dp))
|
||||||
onSend = { send(input) },
|
ChatInput(
|
||||||
enabled = !isStreaming,
|
value = input,
|
||||||
modifier = Modifier.navigationBarsPadding().imePadding(),
|
onValueChange = { input = it },
|
||||||
)
|
onSend = { send(input) },
|
||||||
|
enabled = !isStreaming,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,24 @@ class Haptics(private val vibrator: Vibrator?) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "The answer is arriving" — a soft tick swelling into a firmer one. */
|
/** Very soft — the model received the prompt and starts thinking. */
|
||||||
|
fun thinkingStart() {
|
||||||
|
val vib = active() ?: return
|
||||||
|
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
|
||||||
|
vib.composePrimitives(VibrationEffect.Composition.PRIMITIVE_TICK to 0.22f)
|
||||||
|
} else {
|
||||||
|
vib.predefinedOrOneShot(soft = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "The answer is arriving" — a three-step rising crescendo (the key moment). */
|
||||||
fun responseStart() {
|
fun responseStart() {
|
||||||
val vib = active() ?: return
|
val vib = active() ?: return
|
||||||
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
|
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
|
||||||
vib.composePrimitives(
|
vib.composePrimitives(
|
||||||
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.35f, 0),
|
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.30f, 0),
|
||||||
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.95f, 45),
|
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.60f, 40),
|
||||||
|
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 1.00f, 40),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
vib.predefinedOrOneShot(soft = false)
|
vib.predefinedOrOneShot(soft = false)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
|
||||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue