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(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
|
|
|
|||
|
|
@ -106,14 +106,26 @@ fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = fals
|
|||
CircleShape,
|
||||
),
|
||||
)
|
||||
// Glass sphere
|
||||
// Glass sphere — slightly translucent so the background shimmers through
|
||||
Box(
|
||||
Modifier
|
||||
.size(size)
|
||||
.scale(scale)
|
||||
.clip(CircleShape)
|
||||
.background(Brush.radialGradient(listOf(Color(0xFFF2C879), AmberDeep, Color(0xFF6E4A16))))
|
||||
.border(1.dp, Brush.radialGradient(listOf(Color.White.copy(alpha = 0.5f), Color.Transparent)), CircleShape),
|
||||
.background(
|
||||
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)
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import androidx.compose.foundation.Canvas
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.text.BasicTextField
|
||||
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.Text
|
||||
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.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.AmberDeep
|
||||
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
|
||||
fun KaizenHeader() {
|
||||
|
|
@ -70,55 +79,87 @@ fun KaizenHeader() {
|
|||
@Composable
|
||||
fun EmptyHero(userName: String, onPick: (String) -> Unit) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val hour = remember { LocalTime.now().hour }
|
||||
// One grouped block (orb + greeting + tiles), scrollable so the keyboard never
|
||||
// squeezes or clips it — only the input rises above the keyboard.
|
||||
val now = remember { LocalDateTime.now() }
|
||||
val dateLine = remember {
|
||||
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
|
||||
}
|
||||
// One grouped, scrollable block so the keyboard never squeezes or clips it.
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(64.dp))
|
||||
KaizenOrb(96.dp)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
Text("${greeting(hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
|
||||
KaizenOrb(104.dp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Was liegt an?", color = cs.onBackground, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(36.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SuggestionTile(suggestions[0], Modifier.weight(1f)) { onPick(suggestions[0].prompt) }
|
||||
SuggestionTile(suggestions[1], Modifier.weight(1f)) { onPick(suggestions[1].prompt) }
|
||||
}
|
||||
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) }
|
||||
}
|
||||
Text("Was liegt an?", color = cs.onBackground, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
SuggestionPills(onPick)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
private fun SuggestionPills(onPick: (String) -> Unit) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
Column(
|
||||
modifier
|
||||
.height(112.dp)
|
||||
.clip(RoundedCornerShape(20.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,
|
||||
FlowRow(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(26.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Brush.linearGradient(listOf(Amber, AmberDeep))),
|
||||
)
|
||||
Column {
|
||||
Text(suggestion.title, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||
Text(suggestion.subtitle, color = cs.onSurfaceVariant, fontSize = 12.sp)
|
||||
suggestions.forEach { suggestion ->
|
||||
Row(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(cs.surface.copy(alpha = 0.82f))
|
||||
.border(1.dp, cs.outline, CircleShape)
|
||||
.clickable { onPick(suggestion.prompt) }
|
||||
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||
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)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(Modifier.weight(1f).padding(top = 3.dp)) {
|
||||
if (message.content.isEmpty() && message.streaming) {
|
||||
if (message.thinking) {
|
||||
TypingDots()
|
||||
} else {
|
||||
val display = if (message.streaming) message.content + " ▌" else message.content
|
||||
|
|
@ -192,38 +233,43 @@ fun ChatInput(
|
|||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier
|
||||
.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(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 52.dp)
|
||||
.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(),
|
||||
)
|
||||
GhostIconButton(Icons.Rounded.Add, "Anhang")
|
||||
Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp)) {
|
||||
if (value.isEmpty()) {
|
||||
Text("Nachricht eingeben …", 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(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostIconButton(Icons.Rounded.Call, "Anruf")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
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
|
||||
private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
|
|
@ -233,11 +279,11 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
|||
val fill = if (enabled) {
|
||||
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
|
||||
} else {
|
||||
Modifier.background(cs.surfaceVariant)
|
||||
Modifier.background(cs.surface)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.size(44.dp)
|
||||
.scale(pressScale)
|
||||
.clip(CircleShape)
|
||||
.then(fill)
|
||||
|
|
@ -252,7 +298,7 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
|||
/** Crisp upward arrow drawn with strokes (sharper than a glyph). */
|
||||
@Composable
|
||||
private fun SendArrow(color: Color) {
|
||||
Canvas(Modifier.size(20.dp)) {
|
||||
Canvas(Modifier.size(18.dp)) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val cx = w / 2f
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
|
@ -12,15 +21,29 @@ data class Message(
|
|||
val role: Role,
|
||||
val content: String,
|
||||
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(
|
||||
Suggestion("Brainstorm", "Ideen sammeln", "Lass uns zu einem Thema brainstormen."),
|
||||
Suggestion("Erklär mir", "einfach erklärt", "Erklär mir Quantenverschränkung ganz einfach."),
|
||||
Suggestion("Schreib", "Text & E-Mail", "Schreib eine kurze, freundliche E-Mail an mein Team."),
|
||||
Suggestion("Code", "Programmieren", "Schreib eine Kotlin-Funktion, die Primzahlen findet."),
|
||||
Suggestion("Brainstormen", "Lass uns zu einem Thema brainstormen.", Icons.Rounded.AutoAwesome),
|
||||
Suggestion("Bild generieren", "Generiere ein Bild von einem Bergsee bei Sonnenaufgang.", Icons.Rounded.Image),
|
||||
Suggestion("Video erstellen", "Erstelle ein kurzes Video von einer Stadt bei Nacht.", Icons.Rounded.Movie),
|
||||
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) {
|
||||
|
|
@ -30,7 +53,10 @@ fun greeting(hour: Int): String = when (hour) {
|
|||
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(
|
||||
"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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.kaizen.app.haptics.rememberHaptics
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
|
@ -34,6 +37,7 @@ fun ChatScreen(userName: String = "Bruno") {
|
|||
var input by remember { mutableStateOf("") }
|
||||
var isStreaming by remember { mutableStateOf(false) }
|
||||
var nextId by remember { mutableStateOf(0L) }
|
||||
var selectedMode by remember { mutableStateOf("Standard") }
|
||||
|
||||
fun send(text: String) {
|
||||
val trimmed = text.trim()
|
||||
|
|
@ -42,20 +46,26 @@ fun ChatScreen(userName: String = "Bruno") {
|
|||
messages.add(Message(nextId++, Role.User, trimmed))
|
||||
input = ""
|
||||
val assistantId = nextId++
|
||||
messages.add(Message(assistantId, Role.Assistant, "", streaming = true))
|
||||
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true))
|
||||
isStreaming = true
|
||||
scope.launch {
|
||||
// Thinking phase — variable, like a real model
|
||||
haptics.thinkingStart()
|
||||
delay(thinkingDurationMs())
|
||||
var first = true
|
||||
fakeReply(trimmed).collect { chunk ->
|
||||
if (first) {
|
||||
haptics.responseStart() // "the answer is arriving"
|
||||
first = false
|
||||
}
|
||||
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 }
|
||||
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false)
|
||||
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
||||
isStreaming = false
|
||||
haptics.responseEnd()
|
||||
}
|
||||
|
|
@ -84,13 +94,16 @@ fun ChatScreen(userName: String = "Bruno") {
|
|||
}
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
onSend = { send(input) },
|
||||
enabled = !isStreaming,
|
||||
modifier = Modifier.navigationBarsPadding().imePadding(),
|
||||
)
|
||||
Column(Modifier.navigationBarsPadding().imePadding()) {
|
||||
ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() })
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ChatInput(
|
||||
value = input,
|
||||
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() {
|
||||
val vib = active() ?: return
|
||||
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
|
||||
vib.composePrimitives(
|
||||
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.35f, 0),
|
||||
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.95f, 45),
|
||||
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.30f, 0),
|
||||
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.60f, 40),
|
||||
Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 1.00f, 40),
|
||||
)
|
||||
} else {
|
||||
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-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue