Add Phase 0 feel-prototype: chat screen, mesh/orb design, mock streaming, haptics

This commit is contained in:
Bruno Deanoz 2026-06-18 15:26:32 +02:00
parent a5a6dbff61
commit 66f83b97a7
10 changed files with 716 additions and 84 deletions

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# Kaizen — Android
Native Android-App für Kaizen (Jetpack Compose + Kotlin). Kein WebView, kein Tauri.
## Phase 0 — Feel-Prototyp
Ein einziger Chat-Screen, der das **Gefühl** treffen soll (snappy, Design, Haptik) —
**noch ohne Backend**. Die KI-Antworten sind ein gefakter Stream (`fakeReply`), damit
das Feel ohne Token-Auth/SSE testbar ist.
Drin:
- Obsidian/Amber-Design + Mesh-/Blob-Hintergrund (`Background.kt`)
- Liquid-Glass-`KaizenOrb` (Hero + Assistant-Avatar, atmet, schneller beim Streamen)
- Empty-Hero mit Begrüßung + Vorschlags-Kacheln
- Mock-Streaming Wort für Wort mit natürlichem Rhythmus
- **Haptik** (`haptics/Haptics.kt`): Klick beim Senden, „Rise" wenn die Antwort kommt,
sanftes Absetzen am Ende, Tick bei Kachel-Auswahl — feine Composition-Primitives
auf API 30+, mit Fallbacks darunter.
Bewusst **noch nicht** drin: Backend, Auth, Settings, Bild/Video, Local LLM.
## Bauen & laufen
> **Wichtig:** Auf dem **echten S25 Ultra** laufen lassen, nicht im Emulator —
> der Emulator kann keine echte Haptik, und genau die wollen wir fühlen.
1. Projekt in Android Studio öffnen, Gradle syncen lassen.
2. S25 Ultra per **Wireless Debugging** (Einstellungen → Entwickleroptionen) oder USB verbinden.
3. Run ▶ auf das Gerät.
## Der Test
Eine Woche nutzen. Geht der Finger anfangen hierher statt zu Gemini → wir bauen voll aus
(Token-Auth + echtes SSE-Streaming = Phase 1). Wenn nicht → wir wissen billig, woran's liegt.

View file

@ -1,47 +1,26 @@
package dev.kaizen.app
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import dev.kaizen.app.chat.ChatScreen
import dev.kaizen.app.ui.theme.KaizenTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Helle System-Bar-Icons über dem dunklen Obsidian-Hintergrund
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
)
setContent {
KaizenTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
ChatScreen()
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
KaizenTheme {
Greeting("Android")
}
}

View file

@ -0,0 +1,120 @@
package dev.kaizen.app.chat
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.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.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.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.kaizen.app.ui.theme.Amber
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. */
@Composable
fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
val transition = rememberInfiniteTransition(label = "blobs")
val drift by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
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))),
),
)
content()
}
}
@Composable
private fun BoxScope.Blob(color: Color, size: Dp, x: Dp, y: Dp, alpha: Float) {
Box(
Modifier
.offset(x, y)
.size(size)
.blur(96.dp, BlurredEdgeTreatment.Unbounded)
.background(
Brush.radialGradient(listOf(color.copy(alpha = alpha), Color.Transparent)),
CircleShape,
),
)
}
/** Wiederverwendbarer Liquid-Glass-Orb (Hero + Assistant-Avatar). */
@Composable
fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = false) {
val transition = rememberInfiniteTransition(label = "orb")
val scale by transition.animateFloat(
initialValue = 1f,
targetValue = if (streaming) 1.06f else 1.03f,
animationSpec = infiniteRepeatable(
tween(if (streaming) 2400 else 6500, easing = FastOutSlowInEasing),
RepeatMode.Reverse,
),
label = "breath",
)
Box(modifier.size(size), contentAlignment = Alignment.Center) {
// Halo
Box(
Modifier
.size(size * 1.5f)
.scale(scale)
.blur(24.dp, BlurredEdgeTreatment.Unbounded)
.background(
Brush.radialGradient(
listOf(Amber.copy(alpha = if (streaming) 0.5f else 0.32f), Color.Transparent),
),
CircleShape,
),
)
// Glaskugel
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),
)
// Specular-Highlight oben links ("wet"-Reflexion)
Box(
Modifier
.size(size * 0.42f)
.offset(-(size * 0.15f), -(size * 0.15f))
.clip(CircleShape)
.background(Brush.radialGradient(listOf(Color.White.copy(alpha = 0.7f), Color.Transparent))),
)
}
}

View file

@ -0,0 +1,243 @@
package dev.kaizen.app.chat
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
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() {
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)
}
}
@Composable
fun EmptyHero(userName: String, onPick: (String) -> Unit) {
val hour = remember { LocalTime.now().hour }
Column(
Modifier.fillMaxSize().padding(horizontal = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(56.dp))
KaizenOrb(96.dp)
Spacer(Modifier.height(22.dp))
Text("${greeting(hour)}, $userName", color = TextMuted, fontSize = 17.sp)
Spacer(Modifier.height(6.dp))
Text("Was liegt an?", color = TextPrimary, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
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) }
}
Spacer(Modifier.height(16.dp))
}
}
@Composable
private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier, onClick: () -> Unit) {
Column(
modifier
.height(96.dp)
.clip(RoundedCornerShape(20.dp))
.background(ObsidianElevated.copy(alpha = 0.72f))
.border(1.dp, GlassStroke, RoundedCornerShape(20.dp))
.clickable { onClick() }
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Box(
Modifier
.size(26.dp)
.clip(RoundedCornerShape(8.dp))
.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)
}
}
}
@Composable
fun MessageRow(message: Message) {
if (message.role == Role.User) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp)
Box(
Modifier
.widthIn(max = 300.dp)
.clip(shape)
.background(Amber.copy(alpha = 0.16f))
.border(1.dp, Amber.copy(alpha = 0.28f), shape)
.padding(horizontal = 16.dp, vertical = 11.dp),
) {
Text(message.content, color = TextPrimary, fontSize = 16.sp, lineHeight = 22.sp)
}
}
} else {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
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) {
TypingDots()
} else {
val display = if (message.streaming) message.content + "" else message.content
Text(display, color = TextPrimary, fontSize = 16.sp, lineHeight = 23.sp)
}
}
}
}
}
@Composable
private fun TypingDots() {
val transition = rememberInfiniteTransition(label = "typing")
Row(verticalAlignment = Alignment.CenterVertically) {
repeat(3) { i ->
val alpha by transition.animateFloat(
initialValue = 0.25f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(600, delayMillis = i * 160, easing = FastOutSlowInEasing),
RepeatMode.Reverse,
),
label = "dot$i",
)
Box(
Modifier
.padding(end = 5.dp)
.size(7.dp)
.clip(CircleShape)
.background(TextMuted.copy(alpha = alpha)),
)
}
}
}
@Composable
fun ChatInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom,
) {
Row(
Modifier
.weight(1f)
.heightIn(min = 52.dp)
.clip(RoundedCornerShape(26.dp))
.background(ObsidianInput.copy(alpha = 0.94f))
.border(1.dp, GlassStroke, 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)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(Amber),
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.width(10.dp))
SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend)
}
}
@Composable
private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
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) {
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
} else {
Modifier.background(ObsidianElevated)
}
Box(
Modifier
.size(52.dp)
.scale(pressScale)
.clip(CircleShape)
.then(background)
.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,
)
}
}

View file

@ -0,0 +1,60 @@
package dev.kaizen.app.chat
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.random.Random
enum class Role { User, Assistant }
data class Message(
val id: Long,
val role: Role,
val content: String,
val streaming: Boolean = false,
)
data class Suggestion(val title: String, val subtitle: String, val prompt: String)
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."),
)
fun greeting(hour: Int): String = when (hour) {
in 5..11 -> "Guten Morgen"
in 12..17 -> "Guten Tag"
in 18..22 -> "Guten Abend"
else -> "Hallo"
}
// --- Phase 0: gefakter Stream, damit das Feel ohne Backend testbar ist ---
private val cannedReplies = listOf(
"Klar, das kriegen wir hin. Lass uns das Schritt für Schritt durchgehen: " +
"Zuerst schauen wir uns das Ziel an, dann sammeln wir die wichtigsten Punkte und " +
"bringen sie in eine sinnvolle Reihenfolge. Sag mir einfach, wo du starten willst.",
"Gute Frage. Im Kern geht es darum, das Wesentliche vom Rauschen zu trennen. " +
"Ich würde es so angehen: erst die Grundidee in einem Satz, dann zwei, drei " +
"konkrete Beispiele, und am Ende ein kurzes Fazit, das alles zusammenhält.",
"Mach ich. Hier ist ein erster Entwurf, den wir danach gemeinsam schärfen können. " +
"Er ist bewusst kompakt gehalten — sag mir, ob der Ton passt oder ob ich es " +
"lockerer, formeller oder ausführlicher formulieren soll.",
)
fun fakeReply(prompt: String): Flow<String> = flow {
val reply = cannedReplies[Random.nextInt(cannedReplies.size)]
val tokens = tokenize(reply)
for (token in tokens) {
emit(token)
val base = Random.nextLong(26L, 64L)
// kleine Pause nach Satzzeichen → natürlicherer Rhythmus
val pause = if (token.endsWith(". ") || token.endsWith(": ")) base + 130L else base
delay(pause)
}
}
private fun tokenize(text: String): List<String> =
text.split(" ").mapIndexed { i, word -> if (i == 0) word else " $word" }

View file

@ -0,0 +1,96 @@
package dev.kaizen.app.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.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.launch
@Composable
fun ChatScreen(userName: String = "Bruno") {
val haptics = rememberHaptics()
val scope = rememberCoroutineScope()
val messages = remember { mutableStateListOf<Message>() }
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) }
fun send(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty() || isStreaming) return
haptics.click()
messages.add(Message(nextId++, Role.User, trimmed))
input = ""
val assistantId = nextId++
messages.add(Message(assistantId, Role.Assistant, "", streaming = true))
isStreaming = true
scope.launch {
var first = true
fakeReply(trimmed).collect { chunk ->
if (first) {
haptics.responseStart() // "die Antwort kommt"
first = false
}
val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) messages[idx] = 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)
isStreaming = false
haptics.responseEnd()
}
}
// Beim Wachsen des Streams ans Ende scrollen
val lastLength = messages.lastOrNull()?.content?.length ?: 0
LaunchedEffect(messages.size, lastLength) {
if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
}
MeshBackground {
Column(Modifier.fillMaxSize().statusBarsPadding()) {
KaizenHeader()
Box(Modifier.weight(1f).fillMaxWidth()) {
if (messages.isEmpty()) {
EmptyHero(userName = userName, onPick = { haptics.tick(); send(it) })
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(messages, key = { it.id }) { message -> MessageRow(message) }
}
}
}
ChatInput(
value = input,
onValueChange = { input = it },
onSend = { send(input) },
enabled = !isStreaming,
modifier = Modifier.navigationBarsPadding().imePadding(),
)
}
}
}

View file

@ -0,0 +1,114 @@
package dev.kaizen.app.haptics
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
/**
* Feines haptisches Feedback das, was sich auf dem S25 Ultra "premium" anfühlt.
*
* 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 2628: createOneShot-Fallback.
*
* WICHTIG: Im Emulator gibt es keine echte Haptik immer am echten Gerät testen.
*/
class Haptics(private val vibrator: Vibrator?) {
private fun active(): Vibrator? = vibrator?.takeIf { it.hasVibrator() }
/** Leichter Tap — Kachel-/Mode-Auswahl. */
fun tick() {
val vib = active() ?: return
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
vib.composePrimitives(VibrationEffect.Composition.PRIMITIVE_TICK to 0.55f)
} else {
vib.predefinedOrOneShot(soft = true)
}
}
/** Festerer Klick — Senden-Button. */
fun click() {
val vib = active() ?: return
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_CLICK)) {
vib.composePrimitives(VibrationEffect.Composition.PRIMITIVE_CLICK to 0.85f)
} else {
vib.predefinedOrOneShot(soft = false)
}
}
/** "Die Antwort kommt" — ein leiser Tick, der zu einem festeren anschwillt. */
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),
)
} else {
vib.predefinedOrOneShot(soft = false)
}
}
/** Sanftes Absetzen — Stream fertig. */
fun responseEnd() {
val vib = active() ?: return
if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) {
vib.composePrimitives(VibrationEffect.Composition.PRIMITIVE_TICK to 0.3f)
} else {
vib.predefinedOrOneShot(soft = true)
}
}
// --- intern ---
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>) {
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>) {
val comp = VibrationEffect.startComposition()
for ((primitive, scale, delayMs) in steps) comp.addPrimitive(primitive, scale, delayMs)
vibrate(comp.compose())
}
private fun Vibrator.predefinedOrOneShot(soft: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val effect = if (soft) VibrationEffect.EFFECT_TICK else VibrationEffect.EFFECT_CLICK
vibrate(VibrationEffect.createPredefined(effect))
} else {
vibrate(VibrationEffect.createOneShot(if (soft) 10L else 18L, VibrationEffect.DEFAULT_AMPLITUDE))
}
}
}
/** Baut den passenden Vibrator für die API-Stufe und merkt ihn sich über Recompositions. */
@Composable
fun rememberHaptics(): Haptics {
val context = LocalContext.current
return remember {
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
Haptics(vib)
}
}

View file

@ -2,10 +2,19 @@ package dev.kaizen.app.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
// 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))
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)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// Hintergrund-Blobs (.bg-blobs)
val BlobAmber = Color(0xFFE0A23E)
val BlobBlue = Color(0xFF3C5FD6)
val BlobTeal = Color(0xFF2BB6A6)

View file

@ -1,58 +1,30 @@
package dev.kaizen.app.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
// 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(
primary = Amber,
onPrimary = OnAmber,
secondary = Amber,
onSecondary = OnAmber,
background = Obsidian,
onBackground = TextPrimary,
surface = ObsidianElevated,
onSurface = TextPrimary,
surfaceVariant = ObsidianInput,
onSurfaceVariant = TextMuted,
outline = GlassStroke,
)
@Composable
fun KaizenTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
fun KaizenTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = colorScheme,
colorScheme = KaizenColors,
typography = Typography,
content = content
content = content,
)
}
}

View file

@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Kaizen" parent="android:Theme.Material.Light.NoActionBar" />
</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>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>