Fix send crash (VIBRATE permission), add light/dark theme, redesign send button, group hero to fix keyboard reflow

This commit is contained in:
Bruno Deanoz 2026-06-18 15:39:27 +02:00
parent 66f83b97a7
commit d46949878d
12 changed files with 167 additions and 104 deletions

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Required for haptic feedback (Vibrator.vibrate) -->
<uses-permission android:name="android.permission.VIBRATE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"

View file

@ -12,10 +12,10 @@ import dev.kaizen.app.ui.theme.KaizenTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Helle System-Bar-Icons über dem dunklen Obsidian-Hintergrund // auto() picks light/dark system-bar icons based on the system theme
enableEdgeToEdge( enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
) )
setContent { setContent {
KaizenTheme { KaizenTheme {

View file

@ -1,26 +1,28 @@
package dev.kaizen.app.chat package dev.kaizen.app.chat
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@ -32,11 +34,14 @@ import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.BlobAmber import dev.kaizen.app.ui.theme.BlobAmber
import dev.kaizen.app.ui.theme.BlobBlue import dev.kaizen.app.ui.theme.BlobBlue
import dev.kaizen.app.ui.theme.BlobTeal import dev.kaizen.app.ui.theme.BlobTeal
import dev.kaizen.app.ui.theme.Obsidian
/** Mesh-/Blob-Hintergrund: große, stark verschwommene Farbkreise, die langsam driften. */ /** Mesh/blob background: large, heavily blurred color circles that drift slowly. */
@Composable @Composable
fun MeshBackground(content: @Composable BoxScope.() -> Unit) { fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
val dark = isSystemInDarkTheme()
val base = MaterialTheme.colorScheme.background
// Blobs are subtler in light mode.
val factor = if (dark) 1f else 0.55f
val transition = rememberInfiniteTransition(label = "blobs") val transition = rememberInfiniteTransition(label = "blobs")
val drift by transition.animateFloat( val drift by transition.animateFloat(
initialValue = 0f, initialValue = 0f,
@ -44,16 +49,18 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse), animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
label = "drift", label = "drift",
) )
Box(Modifier.fillMaxSize().background(Obsidian)) { Box(Modifier.fillMaxSize().background(base)) {
Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f) Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor)
Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f) Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor)
Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f) Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)
// Vignette: Ränder abdunkeln, Mitte tritt hervor // Vignette: darken the edges so the center comes forward (dark mode only).
Box( if (dark) {
Modifier.fillMaxSize().background( Box(
Brush.radialGradient(listOf(Color.Transparent, Obsidian.copy(alpha = 0.55f))), Modifier.fillMaxSize().background(
), Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.55f))),
) ),
)
}
content() content()
} }
} }
@ -72,7 +79,7 @@ private fun BoxScope.Blob(color: Color, size: Dp, x: Dp, y: Dp, alpha: Float) {
) )
} }
/** Wiederverwendbarer Liquid-Glass-Orb (Hero + Assistant-Avatar). */ /** Reusable liquid-glass orb (hero + assistant avatar). */
@Composable @Composable
fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = false) { fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = false) {
val transition = rememberInfiniteTransition(label = "orb") val transition = rememberInfiniteTransition(label = "orb")
@ -99,7 +106,7 @@ fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = fals
CircleShape, CircleShape,
), ),
) )
// Glaskugel // Glass sphere
Box( Box(
Modifier Modifier
.size(size) .size(size)
@ -108,7 +115,7 @@ fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = fals
.background(Brush.radialGradient(listOf(Color(0xFFF2C879), AmberDeep, Color(0xFF6E4A16)))) .background(Brush.radialGradient(listOf(Color(0xFFF2C879), AmberDeep, Color(0xFF6E4A16))))
.border(1.dp, Brush.radialGradient(listOf(Color.White.copy(alpha = 0.5f), Color.Transparent)), CircleShape), .border(1.dp, Brush.radialGradient(listOf(Color.White.copy(alpha = 0.5f), Color.Transparent)), CircleShape),
) )
// Specular-Highlight oben links ("wet"-Reflexion) // Specular highlight, top-left ("wet" reflection)
Box( Box(
Modifier Modifier
.size(size * 0.42f) .size(size * 0.42f)

View file

@ -7,6 +7,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
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
@ -25,9 +26,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape 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.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -36,48 +40,50 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
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.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
import androidx.compose.ui.unit.sp 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.GlassStroke
import dev.kaizen.app.ui.theme.ObsidianElevated
import dev.kaizen.app.ui.theme.ObsidianInput
import dev.kaizen.app.ui.theme.OnAmber import dev.kaizen.app.ui.theme.OnAmber
import dev.kaizen.app.ui.theme.TextMuted
import dev.kaizen.app.ui.theme.TextPrimary
import java.time.LocalTime import java.time.LocalTime
@Composable @Composable
fun KaizenHeader() { fun KaizenHeader() {
val cs = MaterialTheme.colorScheme
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
KaizenOrb(24.dp) KaizenOrb(24.dp)
Spacer(Modifier.width(9.dp)) Spacer(Modifier.width(9.dp))
Text("Kaizen", color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
} }
} }
@Composable @Composable
fun EmptyHero(userName: String, onPick: (String) -> Unit) { fun EmptyHero(userName: String, onPick: (String) -> Unit) {
val cs = MaterialTheme.colorScheme
val hour = remember { LocalTime.now().hour } 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.
Column( Column(
Modifier.fillMaxSize().padding(horizontal = 22.dp), Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(Modifier.height(56.dp)) Spacer(Modifier.height(64.dp))
KaizenOrb(96.dp) KaizenOrb(96.dp)
Spacer(Modifier.height(22.dp)) Spacer(Modifier.height(22.dp))
Text("${greeting(hour)}, $userName", color = TextMuted, fontSize = 17.sp) Text("${greeting(hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text("Was liegt an?", color = TextPrimary, fontSize = 30.sp, fontWeight = FontWeight.Bold) Text("Was liegt an?", color = cs.onBackground, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f)) Spacer(Modifier.height(36.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SuggestionTile(suggestions[0], Modifier.weight(1f)) { onPick(suggestions[0].prompt) } SuggestionTile(suggestions[0], Modifier.weight(1f)) { onPick(suggestions[0].prompt) }
SuggestionTile(suggestions[1], Modifier.weight(1f)) { onPick(suggestions[1].prompt) } SuggestionTile(suggestions[1], Modifier.weight(1f)) { onPick(suggestions[1].prompt) }
@ -87,20 +93,21 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit) {
SuggestionTile(suggestions[2], Modifier.weight(1f)) { onPick(suggestions[2].prompt) } SuggestionTile(suggestions[2], Modifier.weight(1f)) { onPick(suggestions[2].prompt) }
SuggestionTile(suggestions[3], Modifier.weight(1f)) { onPick(suggestions[3].prompt) } SuggestionTile(suggestions[3], Modifier.weight(1f)) { onPick(suggestions[3].prompt) }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(24.dp))
} }
} }
@Composable @Composable
private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier, onClick: () -> Unit) { private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme
Column( Column(
modifier modifier
.height(96.dp) .height(112.dp)
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(ObsidianElevated.copy(alpha = 0.72f)) .background(cs.surface.copy(alpha = 0.72f))
.border(1.dp, GlassStroke, RoundedCornerShape(20.dp)) .border(1.dp, cs.outline, RoundedCornerShape(20.dp))
.clickable { onClick() } .clickable { onClick() }
.padding(16.dp), .padding(horizontal = 16.dp, vertical = 15.dp),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
) { ) {
Box( Box(
@ -110,14 +117,15 @@ private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier
.background(Brush.linearGradient(listOf(Amber, AmberDeep))), .background(Brush.linearGradient(listOf(Amber, AmberDeep))),
) )
Column { Column {
Text(suggestion.title, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium) Text(suggestion.title, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(suggestion.subtitle, color = TextMuted, fontSize = 12.sp) Text(suggestion.subtitle, color = cs.onSurfaceVariant, fontSize = 12.sp)
} }
} }
} }
@Composable @Composable
fun MessageRow(message: Message) { fun MessageRow(message: Message) {
val cs = MaterialTheme.colorScheme
if (message.role == Role.User) { if (message.role == Role.User) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp) val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp)
@ -126,10 +134,10 @@ fun MessageRow(message: Message) {
.widthIn(max = 300.dp) .widthIn(max = 300.dp)
.clip(shape) .clip(shape)
.background(Amber.copy(alpha = 0.16f)) .background(Amber.copy(alpha = 0.16f))
.border(1.dp, Amber.copy(alpha = 0.28f), shape) .border(1.dp, Amber.copy(alpha = 0.30f), shape)
.padding(horizontal = 16.dp, vertical = 11.dp), .padding(horizontal = 16.dp, vertical = 11.dp),
) { ) {
Text(message.content, color = TextPrimary, fontSize = 16.sp, lineHeight = 22.sp) Text(message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp)
} }
} }
} else { } else {
@ -141,7 +149,7 @@ fun MessageRow(message: Message) {
TypingDots() TypingDots()
} else { } else {
val display = if (message.streaming) message.content + "" else message.content val display = if (message.streaming) message.content + "" else message.content
Text(display, color = TextPrimary, fontSize = 16.sp, lineHeight = 23.sp) Text(display, color = cs.onBackground, fontSize = 16.sp, lineHeight = 23.sp)
} }
} }
} }
@ -150,6 +158,7 @@ fun MessageRow(message: Message) {
@Composable @Composable
private fun TypingDots() { private fun TypingDots() {
val cs = MaterialTheme.colorScheme
val transition = rememberInfiniteTransition(label = "typing") val transition = rememberInfiniteTransition(label = "typing")
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
repeat(3) { i -> repeat(3) { i ->
@ -167,7 +176,7 @@ private fun TypingDots() {
.padding(end = 5.dp) .padding(end = 5.dp)
.size(7.dp) .size(7.dp)
.clip(CircleShape) .clip(CircleShape)
.background(TextMuted.copy(alpha = alpha)), .background(cs.onSurfaceVariant.copy(alpha = alpha)),
) )
} }
} }
@ -181,6 +190,7 @@ fun ChatInput(
enabled: Boolean, enabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val cs = MaterialTheme.colorScheme
Row( Row(
modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@ -190,19 +200,19 @@ fun ChatInput(
.weight(1f) .weight(1f)
.heightIn(min = 52.dp) .heightIn(min = 52.dp)
.clip(RoundedCornerShape(26.dp)) .clip(RoundedCornerShape(26.dp))
.background(ObsidianInput.copy(alpha = 0.94f)) .background(cs.surfaceVariant.copy(alpha = 0.94f))
.border(1.dp, GlassStroke, RoundedCornerShape(26.dp)) .border(1.dp, cs.outline, RoundedCornerShape(26.dp))
.padding(horizontal = 18.dp, vertical = 15.dp), .padding(horizontal = 18.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(Modifier.weight(1f)) { Box(Modifier.weight(1f)) {
if (value.isEmpty()) { if (value.isEmpty()) {
Text("Frag Kaizen …", color = TextMuted, fontSize = 16.sp) Text("Frag Kaizen …", color = cs.onSurfaceVariant, fontSize = 16.sp)
} }
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, lineHeight = 22.sp), textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(Amber), cursorBrush = SolidColor(Amber),
maxLines = 6, maxLines = 6,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -216,28 +226,39 @@ fun ChatInput(
@Composable @Composable
private fun SendButton(enabled: Boolean, onClick: () -> Unit) { private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send") val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send")
val background = if (enabled) { val fill = if (enabled) {
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep))) Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
} else { } else {
Modifier.background(ObsidianElevated) Modifier.background(cs.surfaceVariant)
} }
Box( Box(
Modifier Modifier
.size(52.dp) .size(52.dp)
.scale(pressScale) .scale(pressScale)
.clip(CircleShape) .clip(CircleShape)
.then(background) .then(fill)
.border(1.dp, cs.outline, CircleShape)
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick), .clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( SendArrow(color = if (enabled) OnAmber else cs.onSurfaceVariant)
"", }
color = if (enabled) OnAmber else TextMuted, }
fontSize = 22.sp,
fontWeight = FontWeight.Bold, /** Crisp upward arrow drawn with strokes (sharper than a glyph). */
) @Composable
private fun SendArrow(color: Color) {
Canvas(Modifier.size(20.dp)) {
val w = size.width
val h = size.height
val cx = w / 2f
val stroke = w * 0.135f
drawLine(color, Offset(cx, h * 0.82f), Offset(cx, h * 0.20f), stroke, StrokeCap.Round)
drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.28f, h * 0.46f), stroke, StrokeCap.Round)
drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.72f, h * 0.46f), stroke, StrokeCap.Round)
} }
} }

View file

@ -30,7 +30,7 @@ fun greeting(hour: Int): String = when (hour) {
else -> "Hallo" else -> "Hallo"
} }
// --- Phase 0: gefakter Stream, damit das Feel ohne Backend testbar ist --- // --- Phase 0: faked stream so the feel is testable without a backend ---
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: " +
@ -50,7 +50,7 @@ fun fakeReply(prompt: String): Flow<String> = flow {
for (token in tokens) { for (token in tokens) {
emit(token) emit(token)
val base = Random.nextLong(26L, 64L) val base = Random.nextLong(26L, 64L)
// kleine Pause nach Satzzeichen → natürlicherer Rhythmus // small pause after punctuation -> more natural rhythm
val pause = if (token.endsWith(". ") || token.endsWith(": ")) base + 130L else base val pause = if (token.endsWith(". ") || token.endsWith(": ")) base + 130L else base
delay(pause) delay(pause)
} }

View file

@ -48,7 +48,7 @@ fun ChatScreen(userName: String = "Bruno") {
var first = true var first = true
fakeReply(trimmed).collect { chunk -> fakeReply(trimmed).collect { chunk ->
if (first) { if (first) {
haptics.responseStart() // "die Antwort kommt" haptics.responseStart() // "the answer is arriving"
first = false first = false
} }
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
@ -61,7 +61,7 @@ fun ChatScreen(userName: String = "Bruno") {
} }
} }
// Beim Wachsen des Streams ans Ende scrollen // Keep pinned to the bottom as the stream grows
val lastLength = messages.lastOrNull()?.content?.length ?: 0 val lastLength = messages.lastOrNull()?.content?.length ?: 0
LaunchedEffect(messages.size, lastLength) { LaunchedEffect(messages.size, lastLength) {
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex) if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)

View file

@ -11,21 +11,22 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
/** /**
* Feines haptisches Feedback das, was sich auf dem S25 Ultra "premium" anfühlt. * Fine-grained haptic feedback the "premium" feel on devices with a good LRA.
* *
* Drei Stufen, je nach Gerät: * Three tiers depending on the device:
* - API 30+ mit gutem Aktuator (LRA): VibrationEffect.Composition mit Primitives (TICK/CLICK) * - API 30+ with a capable actuator: VibrationEffect.Composition with primitives
* kurze, präzise Ticks statt dumpfes "brrr". * (TICK/CLICK) -> short, precise taps instead of a dull buzz.
* - API 29: vordefinierte Effekte (EFFECT_TICK/EFFECT_CLICK). * - API 29: predefined effects (EFFECT_TICK/EFFECT_CLICK).
* - API 2628: createOneShot-Fallback. * - API 26-28: createOneShot fallback.
* *
* WICHTIG: Im Emulator gibt es keine echte Haptik immer am echten Gerät testen. * NOTE: the emulator has no real haptics always evaluate on a physical device.
* All vibrate() calls are wrapped so a missing permission / quirk never crashes.
*/ */
class Haptics(private val vibrator: Vibrator?) { class Haptics(private val vibrator: Vibrator?) {
private fun active(): Vibrator? = vibrator?.takeIf { it.hasVibrator() } private fun active(): Vibrator? = vibrator?.takeIf { it.hasVibrator() }
/** Leichter Tap — Kachel-/Mode-Auswahl. */ /** Light tap — tile / mode selection. */
fun tick() { fun tick() {
val vib = active() ?: return val vib = active() ?: return
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) { if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
@ -35,7 +36,7 @@ class Haptics(private val vibrator: Vibrator?) {
} }
} }
/** Festerer Klick — Senden-Button. */ /** Firmer click — send button. */
fun click() { fun click() {
val vib = active() ?: return val vib = active() ?: return
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_CLICK)) { if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_CLICK)) {
@ -45,7 +46,7 @@ class Haptics(private val vibrator: Vibrator?) {
} }
} }
/** "Die Antwort kommt" — ein leiser Tick, der zu einem festeren anschwillt. */ /** "The answer is arriving" — a soft tick swelling into a firmer one. */
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)) {
@ -58,7 +59,7 @@ class Haptics(private val vibrator: Vibrator?) {
} }
} }
/** Sanftes Absetzen — Stream fertig. */ /** Gentle settle — stream finished. */
fun responseEnd() { fun responseEnd() {
val vib = active() ?: return val vib = active() ?: return
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) { if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
@ -68,27 +69,27 @@ class Haptics(private val vibrator: Vibrator?) {
} }
} }
// --- intern --- // --- internal ---
private fun supportsPrimitive(vib: Vibrator, primitive: Int): Boolean = private fun supportsPrimitive(vib: Vibrator, primitive: Int): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
vib.arePrimitivesSupported(primitive)[0] vib.arePrimitivesSupported(primitive)[0]
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
private fun Vibrator.composePrimitives(vararg steps: Pair<Int, Float>) { private fun Vibrator.composePrimitives(vararg steps: Pair<Int, Float>) = runCatching {
val comp = VibrationEffect.startComposition() val comp = VibrationEffect.startComposition()
for ((primitive, scale) in steps) comp.addPrimitive(primitive, scale) for ((primitive, scale) in steps) comp.addPrimitive(primitive, scale)
vibrate(comp.compose()) vibrate(comp.compose())
} }
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
private fun Vibrator.composePrimitives(vararg steps: Triple<Int, Float, Int>) { private fun Vibrator.composePrimitives(vararg steps: Triple<Int, Float, Int>) = runCatching {
val comp = VibrationEffect.startComposition() val comp = VibrationEffect.startComposition()
for ((primitive, scale, delayMs) in steps) comp.addPrimitive(primitive, scale, delayMs) for ((primitive, scale, delayMs) in steps) comp.addPrimitive(primitive, scale, delayMs)
vibrate(comp.compose()) vibrate(comp.compose())
} }
private fun Vibrator.predefinedOrOneShot(soft: Boolean) { private fun Vibrator.predefinedOrOneShot(soft: Boolean) = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val effect = if (soft) VibrationEffect.EFFECT_TICK else VibrationEffect.EFFECT_CLICK val effect = if (soft) VibrationEffect.EFFECT_TICK else VibrationEffect.EFFECT_CLICK
vibrate(VibrationEffect.createPredefined(effect)) vibrate(VibrationEffect.createPredefined(effect))
@ -98,7 +99,7 @@ class Haptics(private val vibrator: Vibrator?) {
} }
} }
/** Baut den passenden Vibrator für die API-Stufe und merkt ihn sich über Recompositions. */ /** Builds the right Vibrator for the API level and keeps it across recompositions. */
@Composable @Composable
fun rememberHaptics(): Haptics { fun rememberHaptics(): Haptics {
val context = LocalContext.current val context = LocalContext.current

View file

@ -2,19 +2,31 @@ package dev.kaizen.app.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// Kaizen-Palette — Obsidian Stahl-Blau + Amber-Akzent (dark-first). // Kaizen palette — Obsidian steel-blue + amber accent.
// OKLCH-Tokens aus dem Web (globals.css) nach sRGB angenähert; wird am Gerät feinjustiert. // OKLCH tokens from the web (globals.css) approximated to sRGB; fine-tuned on device.
val Obsidian = Color(0xFF0A0E16) // background (~oklch(0.10 0.025 245))
val ObsidianElevated = Color(0xFF141B27) // cards / surfaces // Brand amber — kept constant across light/dark (orb, accents, send button).
val ObsidianInput = Color(0xFF111824) // input surface val Amber = Color(0xFFE0A23E) // ~oklch(0.72 0.14 83)
val Amber = Color(0xFFE0A23E) // primary (~oklch(0.72 0.14 83))
val AmberDeep = Color(0xFFC8862A) val AmberDeep = Color(0xFFC8862A)
val OnAmber = Color(0xFF1B1206) val OnAmber = Color(0xFF1B1206)
val TextPrimary = Color(0xFFE8EBF1)
val TextMuted = Color(0xFF8B94A4)
val GlassStroke = Color(0x16FFFFFF) // dezenter heller Rand (Top-Highlight-Look)
// Hintergrund-Blobs (.bg-blobs) // Dark scheme (Obsidian)
val Obsidian = Color(0xFF0A0E16) // background ~oklch(0.10 0.025 245)
val ObsidianElevated = Color(0xFF141B27) // surface
val ObsidianInput = Color(0xFF111824) // input surface
val DarkText = Color(0xFFE8EBF1)
val DarkMuted = Color(0xFF8B94A4)
val DarkStroke = Color(0x16FFFFFF) // subtle light hairline
// Light scheme
val LightBackground = Color(0xFFF6F7F9)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceVariant = Color(0xFFEEF0F3)
val LightText = Color(0xFF1A1F29)
val LightMuted = Color(0xFF5B6470)
val LightStroke = Color(0x14000000) // subtle dark hairline
// Background blobs (.bg-blobs)
val BlobAmber = Color(0xFFE0A23E) val BlobAmber = Color(0xFFE0A23E)
val BlobBlue = Color(0xFF3C5FD6) val BlobBlue = Color(0xFF3C5FD6)
val BlobTeal = Color(0xFF2BB6A6) val BlobTeal = Color(0xFF2BB6A6)

View file

@ -1,29 +1,48 @@
package dev.kaizen.app.ui.theme package dev.kaizen.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Phase 0: bewusst dark-first und fest (kein System-/Dynamic-Color), damit der // Fixed Kaizen schemes (no dynamic color) so the glass look stays consistent.
// Premium-/Glass-Look konsistent ist. Light-Variante kommt später. private val KaizenDark = darkColorScheme(
private val KaizenColors = darkColorScheme(
primary = Amber, primary = Amber,
onPrimary = OnAmber, onPrimary = OnAmber,
secondary = Amber, secondary = Amber,
onSecondary = OnAmber, onSecondary = OnAmber,
background = Obsidian, background = Obsidian,
onBackground = TextPrimary, onBackground = DarkText,
surface = ObsidianElevated, surface = ObsidianElevated,
onSurface = TextPrimary, onSurface = DarkText,
surfaceVariant = ObsidianInput, surfaceVariant = ObsidianInput,
onSurfaceVariant = TextMuted, onSurfaceVariant = DarkMuted,
outline = GlassStroke, outline = DarkStroke,
)
private val KaizenLight = lightColorScheme(
primary = AmberDeep,
onPrimary = Color.White,
secondary = AmberDeep,
onSecondary = Color.White,
background = LightBackground,
onBackground = LightText,
surface = LightSurface,
onSurface = LightText,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightMuted,
outline = LightStroke,
) )
@Composable @Composable
fun KaizenTheme(content: @Composable () -> Unit) { fun KaizenTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme( MaterialTheme(
colorScheme = KaizenColors, colorScheme = if (darkTheme) KaizenDark else KaizenLight,
typography = Typography, typography = Typography,
content = content, content = content,
) )

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark (Obsidian) window background -->
<color name="window_background">#0A0E16</color>
</resources>

View file

@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="purple_200">#FFBB86FC</color> <!-- Light window background (night override in values-night/) -->
<color name="purple_500">#FF6200EE</color> <color name="window_background">#F6F7F9</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources> </resources>

View file

@ -2,8 +2,8 @@
<resources> <resources>
<style name="Theme.Kaizen" parent="android:Theme.Material.NoActionBar"> <style name="Theme.Kaizen" parent="android:Theme.Material.NoActionBar">
<!-- Obsidian-Window-Background verhindert weißes Aufblitzen vor dem ersten Compose-Frame --> <!-- Adapts to light/dark via values/ + values-night/; avoids a flash before the first Compose frame -->
<item name="android:windowBackground">#0A0E16</item> <item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
</style> </style>