diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b128cb8..a37f2ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) diff --git a/app/src/main/java/dev/kaizen/app/chat/Background.kt b/app/src/main/java/dev/kaizen/app/chat/Background.kt index 2363a3b..4357031 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Background.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Background.kt @@ -106,14 +106,26 @@ fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = fals CircleShape, ), ) - // Glass sphere + // Glass sphere — slightly translucent so the background shimmers through Box( Modifier .size(size) .scale(scale) .clip(CircleShape) - .background(Brush.radialGradient(listOf(Color(0xFFF2C879), AmberDeep, Color(0xFF6E4A16)))) - .border(1.dp, Brush.radialGradient(listOf(Color.White.copy(alpha = 0.5f), Color.Transparent)), CircleShape), + .background( + Brush.radialGradient( + listOf( + Color(0xFFFFE0A8).copy(alpha = 0.94f), + Amber.copy(alpha = 0.80f), + AmberDeep.copy(alpha = 0.90f), + ), + ), + ) + .border( + 1.5.dp, + Brush.radialGradient(listOf(Color.White.copy(alpha = 0.65f), Color.White.copy(alpha = 0.04f))), + CircleShape, + ), ) // Specular highlight, top-left ("wet" reflection) Box( diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 33d4539..35c4bc6 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -11,11 +11,13 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -31,6 +33,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,6 +51,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -52,7 +59,9 @@ import androidx.compose.ui.unit.sp import dev.kaizen.app.ui.theme.Amber import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.OnAmber -import java.time.LocalTime +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale @Composable fun KaizenHeader() { @@ -70,55 +79,87 @@ fun KaizenHeader() { @Composable fun EmptyHero(userName: String, onPick: (String) -> Unit) { val cs = MaterialTheme.colorScheme - val hour = remember { LocalTime.now().hour } - // One grouped block (orb + greeting + tiles), scrollable so the keyboard never - // squeezes or clips it — only the input rises above the keyboard. + val now = remember { LocalDateTime.now() } + val dateLine = remember { + now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN)) + } + // One grouped, scrollable block so the keyboard never squeezes or clips it. Column( - Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 22.dp), + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(64.dp)) - KaizenOrb(96.dp) - Spacer(Modifier.height(22.dp)) - Text("${greeting(hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp) + KaizenOrb(104.dp) + Spacer(Modifier.height(24.dp)) + Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp) Spacer(Modifier.height(6.dp)) - Text("Was liegt an?", color = cs.onBackground, fontSize = 30.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(36.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - SuggestionTile(suggestions[0], Modifier.weight(1f)) { onPick(suggestions[0].prompt) } - SuggestionTile(suggestions[1], Modifier.weight(1f)) { onPick(suggestions[1].prompt) } - } - Spacer(Modifier.height(12.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - SuggestionTile(suggestions[2], Modifier.weight(1f)) { onPick(suggestions[2].prompt) } - SuggestionTile(suggestions[3], Modifier.weight(1f)) { onPick(suggestions[3].prompt) } - } + Text("Was liegt an?", color = cs.onBackground, fontSize = 32.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp) + Spacer(Modifier.height(32.dp)) + SuggestionPills(onPick) Spacer(Modifier.height(24.dp)) } } @Composable -private fun SuggestionTile(suggestion: Suggestion, modifier: Modifier = Modifier, onClick: () -> Unit) { +private fun SuggestionPills(onPick: (String) -> Unit) { val cs = MaterialTheme.colorScheme - Column( - modifier - .height(112.dp) - .clip(RoundedCornerShape(20.dp)) - .background(cs.surface.copy(alpha = 0.72f)) - .border(1.dp, cs.outline, RoundedCornerShape(20.dp)) - .clickable { onClick() } - .padding(horizontal = 16.dp, vertical = 15.dp), - verticalArrangement = Arrangement.SpaceBetween, + FlowRow( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Box( - Modifier - .size(26.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Brush.linearGradient(listOf(Amber, AmberDeep))), - ) - Column { - Text(suggestion.title, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.Medium) - Text(suggestion.subtitle, color = cs.onSurfaceVariant, fontSize = 12.sp) + suggestions.forEach { suggestion -> + Row( + Modifier + .clip(CircleShape) + .background(cs.surface.copy(alpha = 0.82f)) + .border(1.dp, cs.outline, CircleShape) + .clickable { onPick(suggestion.prompt) } + .padding(horizontal = 16.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(suggestion.icon, null, tint = cs.primary, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(suggestion.label, color = cs.onBackground, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +fun ModePillsRow(selected: String, onSelect: (String) -> Unit) { + val cs = MaterialTheme.colorScheme + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + chatModes.forEach { mode -> + val active = mode.label == selected + Row( + Modifier + .clip(CircleShape) + .background(if (active) cs.primary.copy(alpha = 0.16f) else cs.surface.copy(alpha = 0.7f)) + .border(1.dp, if (active) cs.primary.copy(alpha = 0.40f) else cs.outline, CircleShape) + .clickable { onSelect(mode.label) } + .padding(horizontal = 13.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + mode.icon, + null, + tint = if (active) cs.primary else cs.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + mode.label, + color = if (active) cs.onBackground else cs.onSurfaceVariant, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } } } } @@ -145,7 +186,7 @@ fun MessageRow(message: Message) { KaizenOrb(28.dp, streaming = message.streaming) Spacer(Modifier.width(10.dp)) Box(Modifier.weight(1f).padding(top = 3.dp)) { - if (message.content.isEmpty() && message.streaming) { + if (message.thinking) { TypingDots() } else { val display = if (message.streaming) message.content + " ▌" else message.content @@ -192,38 +233,43 @@ fun ChatInput( ) { val cs = MaterialTheme.colorScheme Row( - modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.Bottom, + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(28.dp)) + .background(cs.surfaceVariant.copy(alpha = 0.94f)) + .border(1.dp, cs.outline, RoundedCornerShape(28.dp)) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - Modifier - .weight(1f) - .heightIn(min = 52.dp) - .clip(RoundedCornerShape(26.dp)) - .background(cs.surfaceVariant.copy(alpha = 0.94f)) - .border(1.dp, cs.outline, RoundedCornerShape(26.dp)) - .padding(horizontal = 18.dp, vertical = 15.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(Modifier.weight(1f)) { - if (value.isEmpty()) { - Text("Frag Kaizen …", color = cs.onSurfaceVariant, fontSize = 16.sp) - } - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), - cursorBrush = SolidColor(Amber), - maxLines = 6, - modifier = Modifier.fillMaxWidth(), - ) + GhostIconButton(Icons.Rounded.Add, "Anhang") + Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp)) { + if (value.isEmpty()) { + Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp) } + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), + cursorBrush = SolidColor(Amber), + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + ) } - Spacer(Modifier.width(10.dp)) + GhostIconButton(Icons.Rounded.Call, "Anruf") + Spacer(Modifier.width(4.dp)) SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend) } } +@Composable +private fun GhostIconButton(icon: ImageVector, contentDescription: String) { + val cs = MaterialTheme.colorScheme + Box(Modifier.size(40.dp).clip(CircleShape), contentAlignment = Alignment.Center) { + Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) + } +} + @Composable private fun SendButton(enabled: Boolean, onClick: () -> Unit) { val cs = MaterialTheme.colorScheme @@ -233,11 +279,11 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) { val fill = if (enabled) { Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep))) } else { - Modifier.background(cs.surfaceVariant) + Modifier.background(cs.surface) } Box( Modifier - .size(52.dp) + .size(44.dp) .scale(pressScale) .clip(CircleShape) .then(fill) @@ -252,7 +298,7 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) { /** Crisp upward arrow drawn with strokes (sharper than a glyph). */ @Composable private fun SendArrow(color: Color) { - Canvas(Modifier.size(20.dp)) { + Canvas(Modifier.size(18.dp)) { val w = size.width val h = size.height val cx = w / 2f diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt index 5fcd686..93e3a24 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -1,5 +1,14 @@ package dev.kaizen.app.chat +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Adjust +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Movie +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.ui.graphics.vector.ImageVector import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -12,15 +21,29 @@ data class Message( val role: Role, val content: String, val streaming: Boolean = false, + val thinking: Boolean = false, ) -data class Suggestion(val title: String, val subtitle: String, val prompt: String) +// Empty-state suggestion pills (mirrors the web hero) +data class Suggestion(val label: String, val prompt: String, val icon: ImageVector) val suggestions = listOf( - Suggestion("Brainstorm", "Ideen sammeln", "Lass uns zu einem Thema brainstormen."), - Suggestion("Erklär mir", "einfach erklärt", "Erklär mir Quantenverschränkung ganz einfach."), - Suggestion("Schreib", "Text & E-Mail", "Schreib eine kurze, freundliche E-Mail an mein Team."), - Suggestion("Code", "Programmieren", "Schreib eine Kotlin-Funktion, die Primzahlen findet."), + Suggestion("Brainstormen", "Lass uns zu einem Thema brainstormen.", Icons.Rounded.AutoAwesome), + Suggestion("Bild generieren", "Generiere ein Bild von einem Bergsee bei Sonnenaufgang.", Icons.Rounded.Image), + Suggestion("Video erstellen", "Erstelle ein kurzes Video von einer Stadt bei Nacht.", Icons.Rounded.Movie), + Suggestion("Web durchsuchen", "Durchsuche das Web nach den neuesten KI-Nachrichten.", Icons.Rounded.Language), +) + +// Mode pills above the input (visual only in Phase 0) +data class ChatMode(val label: String, val icon: ImageVector) + +val chatModes = listOf( + ChatMode("Standard", Icons.Rounded.Adjust), + ChatMode("Sampling", Icons.Rounded.Tune), + ChatMode("Bild", Icons.Rounded.Image), + ChatMode("Suche", Icons.Rounded.Language), + ChatMode("Video", Icons.Rounded.Movie), + ChatMode("Audio", Icons.Rounded.MusicNote), ) fun greeting(hour: Int): String = when (hour) { @@ -30,7 +53,10 @@ fun greeting(hour: Int): String = when (hour) { else -> "Hallo" } -// --- Phase 0: faked stream so the feel is testable without a backend --- +// --- Phase 0: faked thinking + stream so the feel is testable without a backend --- + +/** Variable "thinking" time before the answer streams — real models think for different durations. */ +fun thinkingDurationMs(): Long = Random.nextLong(700L, 2600L) private val cannedReplies = listOf( "Klar, das kriegen wir hin. Lass uns das Schritt für Schritt durchgehen: " + diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index fd9ff0d..3608dd2 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -15,14 +17,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.kaizen.app.haptics.rememberHaptics +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -34,6 +37,7 @@ fun ChatScreen(userName: String = "Bruno") { var input by remember { mutableStateOf("") } var isStreaming by remember { mutableStateOf(false) } var nextId by remember { mutableStateOf(0L) } + var selectedMode by remember { mutableStateOf("Standard") } fun send(text: String) { val trimmed = text.trim() @@ -42,20 +46,26 @@ fun ChatScreen(userName: String = "Bruno") { messages.add(Message(nextId++, Role.User, trimmed)) input = "" val assistantId = nextId++ - messages.add(Message(assistantId, Role.Assistant, "", streaming = true)) + messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true)) isStreaming = true scope.launch { + // Thinking phase — variable, like a real model + haptics.thinkingStart() + delay(thinkingDurationMs()) var first = true fakeReply(trimmed).collect { chunk -> - if (first) { - haptics.responseStart() // "the answer is arriving" - first = false - } val idx = messages.indexOfLast { it.id == assistantId } - if (idx >= 0) messages[idx] = messages[idx].copy(content = messages[idx].content + chunk) + if (idx < 0) return@collect + messages[idx] = if (first) { + first = false + haptics.responseStart() // the answer begins + messages[idx].copy(thinking = false, content = chunk) + } else { + messages[idx].copy(content = messages[idx].content + chunk) + } } val idx = messages.indexOfLast { it.id == assistantId } - if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false) + if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) isStreaming = false haptics.responseEnd() } @@ -84,13 +94,16 @@ fun ChatScreen(userName: String = "Bruno") { } } } - ChatInput( - value = input, - onValueChange = { input = it }, - onSend = { send(input) }, - enabled = !isStreaming, - modifier = Modifier.navigationBarsPadding().imePadding(), - ) + Column(Modifier.navigationBarsPadding().imePadding()) { + ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() }) + Spacer(Modifier.height(10.dp)) + ChatInput( + value = input, + onValueChange = { input = it }, + onSend = { send(input) }, + enabled = !isStreaming, + ) + } } } } diff --git a/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt b/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt index 4d6c893..ee74a59 100644 --- a/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt +++ b/app/src/main/java/dev/kaizen/app/haptics/Haptics.kt @@ -46,13 +46,24 @@ class Haptics(private val vibrator: Vibrator?) { } } - /** "The answer is arriving" — a soft tick swelling into a firmer one. */ + /** Very soft — the model received the prompt and starts thinking. */ + fun thinkingStart() { + val vib = active() ?: return + if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) { + vib.composePrimitives(VibrationEffect.Composition.PRIMITIVE_TICK to 0.22f) + } else { + vib.predefinedOrOneShot(soft = true) + } + } + + /** "The answer is arriving" — a three-step rising crescendo (the key moment). */ fun responseStart() { val vib = active() ?: return if (supportsPrimitive(vib, VibrationEffect.Composition.PRIMITIVE_TICK)) { vib.composePrimitives( - Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.35f, 0), - Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.95f, 45), + Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.30f, 0), + Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 0.60f, 40), + Triple(VibrationEffect.Composition.PRIMITIVE_TICK, 1.00f, 40), ) } else { vib.predefinedOrOneShot(soft = false) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08911ae..c360018 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }