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
|
package dev.kaizen.app
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import dev.kaizen.app.chat.ChatScreen
|
||||||
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.ui.theme.KaizenTheme
|
import dev.kaizen.app.ui.theme.KaizenTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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 {
|
setContent {
|
||||||
KaizenTheme {
|
KaizenTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
ChatScreen()
|
||||||
Greeting(
|
|
||||||
name = "Android",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
// Kaizen-Palette — Obsidian Stahl-Blau + Amber-Akzent (dark-first).
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
// OKLCH-Tokens aus dem Web (globals.css) nach sRGB angenähert; wird am Gerät feinjustiert.
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
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)
|
// Hintergrund-Blobs (.bg-blobs)
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val BlobAmber = Color(0xFFE0A23E)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val BlobBlue = Color(0xFF3C5FD6)
|
||||||
|
val BlobTeal = Color(0xFF2BB6A6)
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,30 @@
|
||||||
package dev.kaizen.app.ui.theme
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
// Phase 0: bewusst dark-first und fest (kein System-/Dynamic-Color), damit der
|
||||||
primary = Purple80,
|
// Premium-/Glass-Look konsistent ist. Light-Variante kommt später.
|
||||||
secondary = PurpleGrey80,
|
private val KaizenColors = darkColorScheme(
|
||||||
tertiary = Pink80
|
primary = Amber,
|
||||||
)
|
onPrimary = OnAmber,
|
||||||
|
secondary = Amber,
|
||||||
private val LightColorScheme = lightColorScheme(
|
onSecondary = OnAmber,
|
||||||
primary = Purple40,
|
background = Obsidian,
|
||||||
secondary = PurpleGrey40,
|
onBackground = TextPrimary,
|
||||||
tertiary = Pink40
|
surface = ObsidianElevated,
|
||||||
|
onSurface = TextPrimary,
|
||||||
/* Other default colors to override
|
surfaceVariant = ObsidianInput,
|
||||||
background = Color(0xFFFFFBFE),
|
onSurfaceVariant = TextMuted,
|
||||||
surface = Color(0xFFFFFBFE),
|
outline = GlassStroke,
|
||||||
onPrimary = Color.White,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
onTertiary = Color.White,
|
|
||||||
onBackground = Color(0xFF1C1B1F),
|
|
||||||
onSurface = Color(0xFF1C1B1F),
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KaizenTheme(
|
fun KaizenTheme(content: @Composable () -> Unit) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = KaizenColors,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.Kaizen" parent="android:Theme.Material.Light.NoActionBar" />
|
<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>
|
</resources>
|
||||||
Loading…
Reference in a new issue