Add Phase 0 feel-prototype: chat screen, mesh/orb design, mock streaming, haptics
This commit is contained in:
parent
a5a6dbff61
commit
66f83b97a7
10 changed files with 716 additions and 84 deletions
34
README.md
Normal file
34
README.md
Normal 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.
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
120
app/src/main/java/dev/kaizen/app/chat/Background.kt
Normal file
120
app/src/main/java/dev/kaizen/app/chat/Background.kt
Normal 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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
243
app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt
Normal file
243
app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/dev/kaizen/app/chat/ChatModels.kt
Normal file
60
app/src/main/java/dev/kaizen/app/chat/ChatModels.kt
Normal 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" }
|
||||
96
app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
Normal file
96
app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/src/main/java/dev/kaizen/app/haptics/Haptics.kt
Normal file
114
app/src/main/java/dev/kaizen/app/haptics/Haptics.kt
Normal 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 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<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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue