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

View file

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

View file

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

View file

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

View file

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

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() {
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)

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