Fix send crash (VIBRATE permission), add light/dark theme, redesign send button, group hero to fix keyboard reflow
This commit is contained in:
parent
66f83b97a7
commit
d46949878d
12 changed files with 167 additions and 104 deletions
|
|
@ -2,6 +2,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Required for haptic feedback (Vibrator.vibrate) -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import dev.kaizen.app.ui.theme.KaizenTheme
|
|||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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(
|
||||
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
|
||||
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
|
||||
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||
)
|
||||
setContent {
|
||||
KaizenTheme {
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
package dev.kaizen.app.chat
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
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.BlobBlue
|
||||
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
|
||||
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 drift by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
|
|
@ -44,16 +49,18 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
|
|||
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
|
||||
label = "drift",
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Obsidian)) {
|
||||
Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f)
|
||||
Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f)
|
||||
Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f)
|
||||
// Vignette: Ränder abdunkeln, Mitte tritt hervor
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.radialGradient(listOf(Color.Transparent, Obsidian.copy(alpha = 0.55f))),
|
||||
),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(base)) {
|
||||
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 * factor)
|
||||
Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor)
|
||||
// Vignette: darken the edges so the center comes forward (dark mode only).
|
||||
if (dark) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.55f))),
|
||||
),
|
||||
)
|
||||
}
|
||||
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
|
||||
fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = false) {
|
||||
val transition = rememberInfiniteTransition(label = "orb")
|
||||
|
|
@ -99,7 +106,7 @@ fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = fals
|
|||
CircleShape,
|
||||
),
|
||||
)
|
||||
// Glaskugel
|
||||
// Glass sphere
|
||||
Box(
|
||||
Modifier
|
||||
.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))))
|
||||
.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(
|
||||
Modifier
|
||||
.size(size * 0.42f)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -36,48 +40,50 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
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.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.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.TextMuted
|
||||
import dev.kaizen.app.ui.theme.TextPrimary
|
||||
import java.time.LocalTime
|
||||
|
||||
@Composable
|
||||
fun KaizenHeader() {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
KaizenOrb(24.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
|
||||
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.
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 22.dp),
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Spacer(Modifier.height(64.dp))
|
||||
KaizenOrb(96.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))
|
||||
Text("Was liegt an?", color = TextPrimary, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
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) }
|
||||
|
|
@ -87,20 +93,21 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit) {
|
|||
SuggestionTile(suggestions[2], Modifier.weight(1f)) { onPick(suggestions[2].prompt) }
|
||||
SuggestionTile(suggestions[3], Modifier.weight(1f)) { onPick(suggestions[3].prompt) }
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
Column(
|
||||
modifier
|
||||
.height(96.dp)
|
||||
.height(112.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(ObsidianElevated.copy(alpha = 0.72f))
|
||||
.border(1.dp, GlassStroke, RoundedCornerShape(20.dp))
|
||||
.background(cs.surface.copy(alpha = 0.72f))
|
||||
.border(1.dp, cs.outline, RoundedCornerShape(20.dp))
|
||||
.clickable { onClick() }
|
||||
.padding(16.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 15.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Box(
|
||||
|
|
@ -110,14 +117,15 @@ private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier
|
|||
.background(Brush.linearGradient(listOf(Amber, AmberDeep))),
|
||||
)
|
||||
Column {
|
||||
Text(suggestion.title, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||
Text(suggestion.subtitle, color = TextMuted, fontSize = 12.sp)
|
||||
Text(suggestion.title, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||
Text(suggestion.subtitle, color = cs.onSurfaceVariant, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageRow(message: Message) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
if (message.role == Role.User) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp)
|
||||
|
|
@ -126,10 +134,10 @@ fun MessageRow(message: Message) {
|
|||
.widthIn(max = 300.dp)
|
||||
.clip(shape)
|
||||
.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),
|
||||
) {
|
||||
Text(message.content, color = TextPrimary, fontSize = 16.sp, lineHeight = 22.sp)
|
||||
Text(message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -141,7 +149,7 @@ fun MessageRow(message: Message) {
|
|||
TypingDots()
|
||||
} else {
|
||||
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
|
||||
private fun TypingDots() {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val transition = rememberInfiniteTransition(label = "typing")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(3) { i ->
|
||||
|
|
@ -167,7 +176,7 @@ private fun TypingDots() {
|
|||
.padding(end = 5.dp)
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TextMuted.copy(alpha = alpha)),
|
||||
.background(cs.onSurfaceVariant.copy(alpha = alpha)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +190,7 @@ fun ChatInput(
|
|||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
|
|
@ -190,19 +200,19 @@ fun ChatInput(
|
|||
.weight(1f)
|
||||
.heightIn(min = 52.dp)
|
||||
.clip(RoundedCornerShape(26.dp))
|
||||
.background(ObsidianInput.copy(alpha = 0.94f))
|
||||
.border(1.dp, GlassStroke, 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 = TextMuted, fontSize = 16.sp)
|
||||
Text("Frag Kaizen …", color = cs.onSurfaceVariant, fontSize = 16.sp)
|
||||
}
|
||||
BasicTextField(
|
||||
value = value,
|
||||
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),
|
||||
maxLines = 6,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -216,28 +226,39 @@ fun ChatInput(
|
|||
|
||||
@Composable
|
||||
private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
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)))
|
||||
} else {
|
||||
Modifier.background(ObsidianElevated)
|
||||
Modifier.background(cs.surfaceVariant)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.scale(pressScale)
|
||||
.clip(CircleShape)
|
||||
.then(background)
|
||||
.then(fill)
|
||||
.border(1.dp, cs.outline, CircleShape)
|
||||
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"↑",
|
||||
color = if (enabled) OnAmber else TextMuted,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
SendArrow(color = if (enabled) OnAmber else cs.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ fun greeting(hour: Int): String = when (hour) {
|
|||
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(
|
||||
"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) {
|
||||
emit(token)
|
||||
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
|
||||
delay(pause)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ fun ChatScreen(userName: String = "Bruno") {
|
|||
var first = true
|
||||
fakeReply(trimmed).collect { chunk ->
|
||||
if (first) {
|
||||
haptics.responseStart() // "die Antwort kommt"
|
||||
haptics.responseStart() // "the answer is arriving"
|
||||
first = false
|
||||
}
|
||||
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
|
||||
LaunchedEffect(messages.size, lastLength) {
|
||||
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
|
||||
|
|
|
|||
|
|
@ -11,21 +11,22 @@ import androidx.compose.runtime.remember
|
|||
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:
|
||||
* - API 30+ mit gutem Aktuator (LRA): VibrationEffect.Composition mit Primitives (TICK/CLICK)
|
||||
* → kurze, präzise Ticks statt dumpfes "brrr".
|
||||
* - API 29: vordefinierte Effekte (EFFECT_TICK/EFFECT_CLICK).
|
||||
* - API 26–28: createOneShot-Fallback.
|
||||
* Three tiers depending on the device:
|
||||
* - API 30+ with a capable actuator: VibrationEffect.Composition with primitives
|
||||
* (TICK/CLICK) -> short, precise taps instead of a dull buzz.
|
||||
* - API 29: predefined effects (EFFECT_TICK/EFFECT_CLICK).
|
||||
* - 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?) {
|
||||
|
||||
private fun active(): Vibrator? = vibrator?.takeIf { it.hasVibrator() }
|
||||
|
||||
/** Leichter Tap — Kachel-/Mode-Auswahl. */
|
||||
/** Light tap — tile / mode selection. */
|
||||
fun tick() {
|
||||
val vib = active() ?: return
|
||||
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() {
|
||||
val vib = active() ?: return
|
||||
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() {
|
||||
val vib = active() ?: return
|
||||
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() {
|
||||
val vib = active() ?: return
|
||||
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 =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||
vib.arePrimitivesSupported(primitive)[0]
|
||||
|
||||
@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()
|
||||
for ((primitive, scale) in steps) comp.addPrimitive(primitive, scale)
|
||||
vibrate(comp.compose())
|
||||
}
|
||||
|
||||
@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()
|
||||
for ((primitive, scale, delayMs) in steps) comp.addPrimitive(primitive, scale, delayMs)
|
||||
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) {
|
||||
val effect = if (soft) VibrationEffect.EFFECT_TICK else VibrationEffect.EFFECT_CLICK
|
||||
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
|
||||
fun rememberHaptics(): Haptics {
|
||||
val context = LocalContext.current
|
||||
|
|
|
|||
|
|
@ -2,19 +2,31 @@ package dev.kaizen.app.ui.theme
|
|||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Kaizen-Palette — Obsidian Stahl-Blau + Amber-Akzent (dark-first).
|
||||
// OKLCH-Tokens aus dem Web (globals.css) nach sRGB angenähert; wird am Gerät feinjustiert.
|
||||
val Obsidian = Color(0xFF0A0E16) // background (~oklch(0.10 0.025 245))
|
||||
val ObsidianElevated = Color(0xFF141B27) // cards / surfaces
|
||||
val ObsidianInput = Color(0xFF111824) // input surface
|
||||
val Amber = Color(0xFFE0A23E) // primary (~oklch(0.72 0.14 83))
|
||||
// Kaizen palette — Obsidian steel-blue + amber accent.
|
||||
// OKLCH tokens from the web (globals.css) approximated to sRGB; fine-tuned on device.
|
||||
|
||||
// Brand amber — kept constant across light/dark (orb, accents, send button).
|
||||
val Amber = Color(0xFFE0A23E) // ~oklch(0.72 0.14 83)
|
||||
val AmberDeep = Color(0xFFC8862A)
|
||||
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 BlobBlue = Color(0xFF3C5FD6)
|
||||
val BlobTeal = Color(0xFF2BB6A6)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,48 @@
|
|||
package dev.kaizen.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Phase 0: bewusst dark-first und fest (kein System-/Dynamic-Color), damit der
|
||||
// Premium-/Glass-Look konsistent ist. Light-Variante kommt später.
|
||||
private val KaizenColors = darkColorScheme(
|
||||
// Fixed Kaizen schemes (no dynamic color) so the glass look stays consistent.
|
||||
private val KaizenDark = darkColorScheme(
|
||||
primary = Amber,
|
||||
onPrimary = OnAmber,
|
||||
secondary = Amber,
|
||||
onSecondary = OnAmber,
|
||||
background = Obsidian,
|
||||
onBackground = TextPrimary,
|
||||
onBackground = DarkText,
|
||||
surface = ObsidianElevated,
|
||||
onSurface = TextPrimary,
|
||||
onSurface = DarkText,
|
||||
surfaceVariant = ObsidianInput,
|
||||
onSurfaceVariant = TextMuted,
|
||||
outline = GlassStroke,
|
||||
onSurfaceVariant = DarkMuted,
|
||||
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
|
||||
fun KaizenTheme(content: @Composable () -> Unit) {
|
||||
fun KaizenTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = KaizenColors,
|
||||
colorScheme = if (darkTheme) KaizenDark else KaizenLight,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
|
|
|
|||
5
app/src/main/res/values-night/colors.xml
Normal file
5
app/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Dark (Obsidian) window background -->
|
||||
<color name="window_background">#0A0E16</color>
|
||||
</resources>
|
||||
|
|
@ -1,10 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</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>
|
||||
<!-- Light window background (night override in values-night/) -->
|
||||
<color name="window_background">#F6F7F9</color>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
<resources>
|
||||
|
||||
<style name="Theme.Kaizen" parent="android:Theme.Material.NoActionBar">
|
||||
<!-- Obsidian-Window-Background verhindert weißes Aufblitzen vor dem ersten Compose-Frame -->
|
||||
<item name="android:windowBackground">#0A0E16</item>
|
||||
<!-- Adapts to light/dark via values/ + values-night/; avoids a flash before the first Compose frame -->
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue