diff --git a/README.md b/README.md new file mode 100644 index 0000000..2068fd0 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/src/main/java/dev/kaizen/app/MainActivity.kt b/app/src/main/java/dev/kaizen/app/MainActivity.kt index b68f9a4..4832da3 100644 --- a/app/src/main/java/dev/kaizen/app/MainActivity.kt +++ b/app/src/main/java/dev/kaizen/app/MainActivity.kt @@ -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") - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/kaizen/app/chat/Background.kt b/app/src/main/java/dev/kaizen/app/chat/Background.kt new file mode 100644 index 0000000..00c60e0 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/Background.kt @@ -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))), + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt new file mode 100644 index 0000000..5d2c77b --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt new file mode 100644 index 0000000..d6ee811 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -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 = 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 = + text.split(" ").mapIndexed { i, word -> if (i == 0) word else " $word" } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt new file mode 100644 index 0000000..abf9dfa --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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() } + 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(), + ) + } + } +} diff --git a/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt b/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt new file mode 100644 index 0000000..13735e7 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt @@ -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 26–28: 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) { + 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) { + 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) + } +} diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt index aa37ea8..f1abf22 100644 --- a/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt @@ -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) \ No newline at end of file +// Hintergrund-Blobs (.bg-blobs) +val BlobAmber = Color(0xFFE0A23E) +val BlobBlue = Color(0xFF3C5FD6) +val BlobTeal = Color(0xFF2BB6A6) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt index 5edca73..f678460 100644 --- a/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt @@ -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, ) -} \ No newline at end of file +} diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 8ac8033..d5c6bb5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,10 @@ - +