feat: orb uses rememberTiltState(), update CLAUDE.md with i18n + migration status

KaizenOrb now uses centralized rememberTiltState() (GAME_ROTATION_VECTOR,
lifecycle-aware, battery-saver-aware) instead of inline sensor code.
Breath animation uses Durations tokens from motion system.

CLAUDE.md: updated design system section with GlassSurface correction
(no backdrop-filter on Android), migration status (all components done),
i18n section documenting the de/en string resource architecture.
This commit is contained in:
Bruno Deanoz 2026-06-21 15:04:31 +02:00
parent 0237eae1e6
commit e3d624c961
2 changed files with 21 additions and 62 deletions

View file

@ -48,7 +48,7 @@ ui/
theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither
theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry
shape/ SquircleShape (superellipse), KaizenShapes (token system) shape/ SquircleShape (superellipse), KaizenShapes (token system)
effect/ GlassSurface (RenderEffect blur), Shadow (multi-layer stack) effect/ GlassSurface (frosted glass), Shadow (multi-layer stack)
motion/ Motion (easing curves, spring specs, duration tokens) motion/ Motion (easing curves, spring specs, duration tokens)
sensor/ TiltState (gyro/accel parallax, lifecycle-aware) sensor/ TiltState (gyro/accel parallax, lifecycle-aware)
``` ```
@ -69,14 +69,26 @@ Full spec: `DESIGN.md`. Branch: `feature/design-system-v2`.
1. **P3 color pipeline** — all tokens in DisplayP3 color space (`Color.kt`). `window.colorMode = COLOR_MODE_HDR` (API 34+) / `COLOR_MODE_WIDE_COLOR_GAMUT` (API 26+) in `MainActivity`. No `Color(0xFF…)` for brand colors outside `Color.kt`. 1. **P3 color pipeline** — all tokens in DisplayP3 color space (`Color.kt`). `window.colorMode = COLOR_MODE_HDR` (API 34+) / `COLOR_MODE_WIDE_COLOR_GAMUT` (API 26+) in `MainActivity`. No `Color(0xFF…)` for brand colors outside `Color.kt`.
2. **Squircle shapes** — iOS-style superellipse with G2 continuity (`ui/shape/Squircle.kt`). Token system `KaizenShapes.{xs,sm,md,lg,xl,pill,circle}`. Replaces all `RoundedCornerShape`. 2. **Squircle shapes** — iOS-style superellipse with G2 continuity (`ui/shape/Squircle.kt`). Token system `KaizenShapes.{xs,sm,md,lg,xl,pill,circle}`. Replaces all `RoundedCornerShape`.
3. **GlassSurface**real `Modifier.blur()` (RenderEffect on API 31+), tint + inner highlight + border. API 2630 fallback: higher tint alpha. Opacity tiers per component in `GlassTiers`. 3. **GlassSurface**frosted-glass composable (gradient tint + specular border + inner highlight + multi-layer shadow). Android has no backdrop-filter; glass effect works via semi-transparent tint over the blob layer. Opacity tiers per component in `GlassTiers`.
4. **Multi-layer shadows**`ShadowStack` with 24 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`. 4. **Multi-layer shadows**`ShadowStack` with 24 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`.
5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. Server sync planned via `GET /api/v1/me``uiTheme`. 5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. Server sync planned via `GET /api/v1/me``uiTheme`.
6. **Sensor-reactive motion**`rememberTiltState()` provides smoothed tilt `Offset(-1..1)`. Uses `TYPE_GAME_ROTATION_VECTOR` (fused, no drift). Auto-disables on battery saver, reduced motion, background, extreme tilt. 6. **Sensor-reactive motion**`rememberTiltState()` provides smoothed tilt `Offset(-1..1)`. Uses `TYPE_GAME_ROTATION_VECTOR` (fused, no drift). Auto-disables on battery saver, reduced motion, background, extreme tilt.
**Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object. **Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object.
**What's NOT yet migrated to the design system:** The existing UI components (ChatScreen, Sidebar, ChatInput, etc.) still use the old inline glass styling with manual `Brush` + `border`. Migration to `GlassSurface` + `KaizenShapes` + `kaizenShadow` is the next step. **Migration status:** All UI components migrated to the design system. Sidebar, ChatInput, ChatScreen, LoginScreen, ModelSheet, SettingsCard, SettingsScreen use `GlassSurface` + `KaizenShapes` + `kaizenShadow`. Only Markdown code-block shapes remain as `RoundedCornerShape` (intentionally rectangular for code).
**KaizenOrb** (`chat/Background.kt`) uses `rememberTiltState()` from `ui/sensor/` instead of inline sensor code. Breath animation uses `Durations.ORB_BREATH` / `ORB_BREATH_STREAMING` from `ui/motion/`.
### Internationalization (i18n)
- **de** (default), **en** — same as web (`messages/de.json`, `messages/en.json`).
- Strings: `res/values/strings.xml` (de) + `res/values-en/strings.xml` (en).
- All UI strings use `stringResource(R.string.xxx)` in `@Composable` functions, or `context.getString(R.string.xxx)` in non-composable contexts (error handlers, coroutines).
- Date/time: `DateTimeFormatter` with `Locale.getDefault()`.
- Greeting: time-of-day-based (Morgen/Tag/Abend → Morning/Afternoon/Evening), boundaries 5/12/18.
- ChatModels: `Suggestion.labelRes`/`promptRes` and `ChatMode.labelRes` are resource IDs (Int), not hardcoded strings.
- `ModePillsRow`: `selected` state is `Int` (resource ID), not `String`.
### Offline-first cache layer (Room) — added 2026-06-21 ### Offline-first cache layer (Room) — added 2026-06-21

View file

@ -1,23 +1,14 @@
package dev.kaizen.app.chat package dev.kaizen.app.chat
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
@ -27,11 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.BlurredEdgeTreatment
@ -44,9 +31,10 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.kaizen.app.ui.motion.Durations
import dev.kaizen.app.ui.sensor.rememberTiltState
import dev.kaizen.app.ui.theme.Amber import dev.kaizen.app.ui.theme.Amber
import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.BlobAmber import dev.kaizen.app.ui.theme.BlobAmber
@ -132,58 +120,17 @@ fun KaizenOrb(
initialValue = 1f, initialValue = 1f,
targetValue = if (streaming || recording) 1.05f else 1.025f, targetValue = if (streaming || recording) 1.05f else 1.025f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
tween(if (streaming || recording) 2400 else 7000, easing = FastOutSlowInEasing), tween(if (streaming || recording) Durations.ORB_BREATH_STREAMING else Durations.ORB_BREATH, easing = FastOutSlowInEasing),
RepeatMode.Reverse, RepeatMode.Reverse,
), ),
label = "breath", label = "breath",
) )
// Dynamic scale expansion based on real-time audio amplitude (Call Mode / TTS speech reactivity)
val reactiveScale = amplitude?.let { 1f + it * 0.12f } ?: 1f val reactiveScale = amplitude?.let { 1f + it * 0.12f } ?: 1f
// --- Gyroscope / Accel-based Holographic Parallax Offset --- val tilt by rememberTiltState()
val context = LocalContext.current val tiltX = tilt.x
var rawTiltX by remember { mutableStateOf(0f) } val tiltY = tilt.y
var rawTiltY by remember { mutableStateOf(0f) }
DisposableEffect(Unit) {
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
val gravitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY)
?: sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (event == null) return
// event.values[0] is X (left/right tilt), event.values[1] is Y (forward/backward tilt)
// Normalize gravity acceleration to unit scale [-1.0, 1.0]
val x = event.values[0] / 9.81f
val y = event.values[1] / 9.81f
rawTiltX = x.coerceIn(-1f, 1f)
rawTiltY = y.coerceIn(-1f, 1f)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
if (sensorManager != null && gravitySensor != null) {
sensorManager.registerListener(listener, gravitySensor, SensorManager.SENSOR_DELAY_UI)
}
onDispose {
sensorManager?.unregisterListener(listener)
}
}
// Smooth raw tilt values using native springs for fluid, liquid responsiveness
val tiltX by animateFloatAsState(
targetValue = rawTiltX,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "tiltX"
)
val tiltY by animateFloatAsState(
targetValue = rawTiltY,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "tiltY"
)
Box( Box(
modifier = modifier modifier = modifier