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:
Bruno Deanoz 2026-06-18 15:49:47 +02:00
parent d46949878d
commit 4cd117e703
7 changed files with 204 additions and 94 deletions

View file

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

View file

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

View file

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

View file

@ -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: " +

View file

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

View file

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

View file

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