diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d10647 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,302 @@ +# Kaizen App + +Architecture map for the native Android client. Source of truth for any agent or human picking up this codebase. + +## Goal + +Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks to the **Kaizen backend** (`kaizen`). One binary, deployment-agnostic: one uniform `Authorization: Bearer ` contract, same against the official server or any self-host. Design constraint (hard): backend complexity stays invisible to the user. + +## Repos (absolute paths) + +- Backend (Next.js 16, Drizzle, Postgres): `/Users/brunodeanoz/Kaizen/kaizen` — git remote `origin` = `git@git.kryptomrx.de:krypto/kaizen.git`, branch `main`. Read its `CLAUDE.md` first; it is the architecture map. +- Android app (Kotlin, Jetpack Compose, package `dev.kaizen.app`): `/Users/brunodeanoz/Kaizen/kaizen-app` — built/run from Windows Android Studio on a real S25 Ultra; everything else (backend, Postgres, Docker) runs on the dev Mac + dedicated servers. +- **Note:** `/Users/brunodeanoz/Kaizen` itself is NOT a git repo. Only `kaizen` and `kaizen-app` are separate git repos. + +## Platform strategy + +- Android: Kotlin + Jetpack Compose (native, no WebView) +- iOS (planned): Swift + SwiftUI + SwiftData (native, separate codebase) +- No KMP — each platform uses its native stack for best feel + +## The API contract the app uses (source of truth — verify, don't guess) + +- **Auth:** every request carries `Authorization: Bearer kzn_...`. Backend resolves via `requireActiveUserOrToken` (`lib/auth/guard.ts`). +- **Token acquisition:** in-app login (`POST {baseUrl}/api/v1/auth/token` with `{ email, password, name }`). Consumer "Sign in" pattern — no copy-paste. +- **Chat:** `POST {baseUrl}/api/v1/chat` — `{ model, messages: [{ role, content }], reasoningEffort?, preferThroughput? }`. Response is `text/plain` chunked stream (NOT SSE). Parsed by `StreamConsumer.Incremental` in `net/StreamConsumer.kt`. +- **Sentinels** (`lib/chat/constants.ts`): `\x00` USAGE, `\x01` REASONING, `\x02` SOURCES, `\x03` TOOL, `\x04` QUERY. App currently strips all sentinels and renders only visible content. +- **Models:** `GET {baseUrl}/api/v1/models` → `{ models: [{ id, name?, provider? }] }`. +- **Conversations:** `GET /api/v1/conversations` → `{ conversations, hasMore }`. `POST /api/v1/conversations` → create. `GET /api/v1/conversations/[id]` → `{ messages }`. `POST /api/v1/conversations/[id]/messages` → persist. `POST /api/v1/conversations/[id]/title` → auto-title. +- **Identity:** `GET /api/v1/me` → `{ id, email, name, role, defaultModel }`. +- **Capability handshake:** `GET /api/v1/meta` → `{ app, apiVersion, features }` (unauth). +- **Upload:** `POST /api/v1/upload` — multipart file → `{ url, kind, name, mimeType, size, fileId }`. + +## App architecture + +### Package structure (`app/src/main/java/dev/kaizen/app/`) + +``` +auth/ LoginScreen +chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels, + ModelSheet, Markdown, Background +db/ Room offline cache: KaizenDatabase, Entities, DAOs, + Repositories, Converters, Mappers +haptics/ Phase-aware haptics +net/ KaizenApi (HTTP), SecureStore (encrypted prefs), + SessionViewModel, ServerConfig, StreamConsumer +settings/ SettingsScreen, SettingsViewModel, SettingsComponents +ui/ + theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither + theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry + shape/ SquircleShape (superellipse), KaizenShapes (token system) + effect/ GlassSurface (frosted glass), Shadow (multi-layer stack) + motion/ Motion (easing curves, spring specs, duration tokens) + sensor/ TiltState (gyro/accel parallax, lifecycle-aware) +``` + +### Key components + +- **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Streaming chat returns `Flow` with incremental sentinel-stripping. +- **StreamConsumer** (`net/StreamConsumer.kt`) — O(chunk-size) incremental parser, each character visited exactly once across the entire stream. +- **SessionViewModel** (`net/SessionViewModel.kt`) — Compose snapshot state for auth session. Holds `ServerConfig` (baseUrl, token, model, email, speed). Persisted in `EncryptedSharedPreferences` via `SecureStore`. +- **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreen`. +- **ChatScreen** (`chat/ChatScreen.kt`) — main composable, orchestrates sidebar, messages, streaming, file uploads. + +### Design System v2 (`ui/`) — added 2026-06-21 + +Full spec: `DESIGN.md`. + +**Six pillars** (load-bearing — if any regress, the design regresses): + +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`. +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 2–4 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. Theme selection wired end-to-end: `SettingsViewModel.selectedTheme` → `MainActivity` → `KaizenTheme(themeId=...)`. No server sync (web stores in localStorage, app stores locally). Appearance (Light/Dark/System) also wired end-to-end. +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. + +**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/`. + +**Typography** — Inter Variable (`res/font/inter_variable.ttf`, 859 KB, SIL OFL). Full type scale from DESIGN.md §8: `displayLarge` (32sp/W600) through `labelSmall` (11sp/W500). All 9 weight axes registered (W100–W900). Negative tracking at display sizes, positive at label sizes. `@OptIn(ExperimentalTextApi::class)` for `FontVariation.Settings`. + +**Oklab** (`ui/theme/Oklab.kt`) — Double-precision matrix math for accurate sRGB↔Oklab round-trips. Used by theme picker's Oklab gradient swatches and `generateGradientStops()`. Forward LMS matrix corrected to Björn Ottosson reference. + +### 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`. + +### Settings persistence + server sync + +- **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale survive app restart. +- Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora"). +- Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System"). +- On login + on each foreground: `KaizenApi.fetchMe()` → `MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email. +- **Language selector** in Settings: System / Deutsch / English. Stored as `locale` in SecureStore. Note: currently only stores the preference; runtime locale switching (AppCompatDelegate.setApplicationLocales) needs wiring in MainActivity on next iteration. +- No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local. + +### Sentinel parsing — reasoning, sources, tools (added 2026-06-21) + +The `/api/v1/chat` stream embeds control sections via sentinel code points (from `lib/chat/constants.ts`): + +| Sentinel | Code | What | App UI | +|----------|------|------|--------| +| `\x00` USAGE | trailing | Usage/cost JSON | Stripped (display TODO) | +| `\x01` REASONING | toggle | Model's chain-of-thought | `ReasoningBlock` — collapsible "Gedankengang" | +| `\x02` SOURCES | trailing | Web search sources JSON | `SourcesBlock` — compact cards with domain | +| `\x03` TOOL | leading | Tool-call events (JSON+newline) | `ToolEventsBlock` — status icons per step | +| `\x04` QUERY | trailing | Search query string | Shown above sources | + +**StreamConsumer.Incremental** (`net/StreamConsumer.kt`) returns `StreamState` (not `String`): +- `content` — visible text (reasoning stripped, tool events stripped) +- `reasoning` — full reasoning text +- `tools` — `List` (type, name, args, result, error, elapsed) +- `query` — search query string +- `sources` — `List` (title, url, snippet) + +**KaizenApi.chat()** returns `Flow`. ChatScreen maps all fields to `Message` during streaming. + +**UI files:** `chat/StreamBlocks.kt` — `ReasoningBlock`, `ToolEventsBlock`, `SourcesBlock`, `SourceChip`. + +### App icon + +Adaptive icon: amber orb with multi-layer radial gradients (body, depth shadow, specular highlight, warm bounce light, crisp rim). Light mode: warm off-white background. Dark mode: Obsidian gradient. Monochrome layer for Android 13+ themed icons. Splash screen uses orb foreground (API 31+). Vector-only — no raster WebPs. + +### Offline-first cache layer (Room) — added 2026-06-21 + +Architecture decision document: `OFFLINE_CACHE_ADD.md`. + +**Principle:** Read-cache, not sync engine. Server is source of truth. No offline-queue, no conflict resolution. + +**Stack:** Room 2.8.4 + KSP 2.2.10-2.0.2. `room-ktx` merged into `room-runtime` since Room 2.7 (no separate dep needed). `exportSchema = false`, `fallbackToDestructiveMigration()` — it's a cache, not a DB. On schema changes the SQLite is wiped; cache rebuilds on next server fetch. + +**Schema:** +- `conversations` — id, title, locked, pinned, updatedAt, cachedAt +- `messages` — id, conversationId (FK CASCADE), role, content, attachments (JSON via TypeConverter), sortOrder + +**Data flow:** +``` +App start → Room (instant) → UI shows cached data + → Background: server fetch → Room update → Flow emits → UI updates +Chat open → Room (instant) → Background: server fetch → Room update +Send message → in-memory during streaming → Room flush after stream ends +``` + +**Streaming hybrid model:** During active streaming, messages are mutated in-memory (60x/s). Room is NOT written during streaming — that would be absurd I/O. After stream completion, user + assistant messages are upserted into Room. This avoids SQLite lock contention while keeping the cache fresh. + +**Files:** +- `db/Entities.kt` — `ConversationEntity`, `MessageEntity` +- `db/ConversationDao.kt` — `observeAll()` (Flow), `replaceAll()` (transaction: deleteAll + upsertAll, avoids SQLite 999-variable limit), `lastCachedAt()` +- `db/MessageDao.kt` — `observeByConversation()`, `getByConversation()`, `upsertAll()`, `deleteByConversation()` +- `db/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe +- `db/ConversationRepository.kt` — `observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room) +- `db/MessageRepository.kt` — `observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()` +- `db/Converters.kt` — `List` ↔ JSON string via kotlinx.serialization +- `db/Mappers.kt` — `ConversationSummary` ↔ `ConversationEntity`, `StoredMessage` ↔ `MessageEntity` + +**AGP 9.x compat:** `android.disallowKotlinSourceSets=false` in `gradle.properties` (KSP issue [#2729](https://github.com/google/ksp/issues/2729)). + +## What's DONE and verified + +### Backend — all complete, deployed, tested + +All backend work for the app is done. `pnpm test:run` green (774+), `tsc` + `lint` clean. + +- **`api_tokens` table** + SHA-256 hash storage (never raw). Migration `0024`. +- **Bearer auth path** (`getTokenSession` / `requireActiveUserOrToken` in `lib/auth/guard.ts`). +- **Token mint endpoint** `POST /api/v1/auth/token` — unauthenticated, Argon2id verify, anti-enumeration, rate-limited. +- **`GET /api/v1/me`** — identity + `defaultModel` for native clients. +- **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog. +- **`/api/v1/conversations`** — thin re-exports of canonical handlers, Bearer-authed. +- **`GET /api/v1/meta`** — capability handshake. +- **App-devices card** (`components/settings/app-tokens-card.tsx`) — see/revoke signed-in devices on `/settings/security`. +- **API versioning** — `/api/v1/` for outward contract, additive-only within v1. Web-internal endpoints stay unversioned. + +### App — shipped features (on-device verified on S25 Ultra) + +All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKotlin` clean). + +| Commit | Feature | +|--------|---------| +| `44c17b6` | In-app login (email+password → `POST /api/v1/auth/token`), `EncryptedSharedPreferences`, real streaming via `StreamConsumer.Incremental`, `ServerConfig` persistence | +| `aa0a97b` | Dither overlay to kill 8-bit sRGB banding on orb + mesh gradients | +| `d2f269f` | Model + speed picker (top pill → bottom sheet), response-speed segment, favorites, provider badges | +| `ea5e625` | Sidebar wired to real conversations, persistence (create/save/load/auto-title), date-grouped history | +| `7399c73` | Model sheet redesign with OpenRouter/Vertex badging, offline-first caching | +| `d81c6ff` | Robust JSON parsing (malformed response handling) | +| `827a2f3` | Streaming performance, error handling | +| `423abc0` | **Specific error diagnostics** — `FetchResult` replaces null-returns; error banner shows exact cause (HTTP status, DNS, SSL, timeout, JSON parsing) instead of generic "Verbindung zum Server fehlgeschlagen" | +| `4616ef2` | **Markdown rendering with syntax-highlighted code blocks** — native Compose renderer: headings, bold/italic, inline code, fenced code blocks with keyword highlighting (15+ languages), ordered/unordered lists, streaming cursor integration | +| `d8dcb25` | **Attachment support — display, upload, and send files** — parse + render message attachments from loaded conversations (images inline via OkHttp + in-memory cache, other kinds as icon chips); + button wired to Android file picker with upload to `/api/v1/upload`, progress chips, send attachments with chat requests + persist in saved messages. Also: Markdown horizontal rules, blockquotes, links, code block copy button | +| `9759d47` | **Offline-first cache layer (Room)** — sidebar + messages load instantly from SQLite cache, background refresh syncs with server, streaming hybrid model (in-memory during stream, Room flush after), ChatViewModel, repositories, mappers, TypeConverters, unit tests | + +**Earlier UI/feel work (Phase 0 prototype → feel-first):** +- Liquid glass styling, floating panels, glassmorphic sidebar +- KaizenOrb with 3D sphere rendering, streaming animation +- Mesh gradient background with dithered blobs +- Phase-aware haptics (click → thinkingStart → responseStart → responseEnd) +- Tablet/landscape optimization (width capping) +- Dynamic keyboard handling (suggestion pills hide, spacer shrinks) +- Settings screen with SettingsViewModel + +### Bug fixed this session (2026-06-19/20) + +**"Verbindung zum Server fehlgeschlagen" on S25** — root cause was **server running an old build** without the v1/conversations route (404) and without Bearer auth on models (401). Fix was `git pull && docker compose -f docker-compose.prod.yml up -d --build` on the server. Additionally, the error diagnostics were improved (see `423abc0`) so future issues show the exact cause in the UI banner instead of a generic message. + +## What's NOT done yet (app, priority order) + +### 1. Remaining visual/UX gaps (daily-drive blockers) + +These are what separate "prototype" from "daily-driver": + +- [ ] **Markdown rendering during streaming** — currently renders live but performance with long code blocks during high-speed streaming needs real-device verification +- [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback) +- [x] **Horizontal rule / blockquote rendering** — `---` and `> quote` not yet parsed +- [ ] **Table rendering** — Markdown tables render as raw text +- [x] **Link rendering** — styled visually, not yet clickable (tap-to-open pending) + +### 2. Conversation management from the app + +- [ ] **Conversation rename/delete** from the sidebar (currently read-only) +- [ ] **Conversation pin/unpin** from the sidebar +- [ ] **In-app unlock for locked conversations** (currently skipped with "Gesperrter Chat" label) + +### 3. Security hardening + +- [ ] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner +- [ ] **Server version check** — call `/api/v1/meta` after login to detect server skew and warn cleanly +- [ ] **Biometric lock** (`BiometricPrompt` + KeyStore binding) — see `LATER.md` §1C +- [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B + +### 4. Features for parity with the web + +- [x] **Attachments** — image/file upload, attachment preview in messages, display in loaded conversations (camera capture still TODO) +- [x] **Reasoning/thinking display** — `ReasoningBlock` (collapsible), parses `\x01` sentinel +- [x] **Web search sources** — `SourcesBlock` + `SourceChip`, parses `\x02`/`\x04` sentinels +- [x] **Tool call display** — `ToolEventsBlock`, parses `\x03` sentinel, status icons per step +- [ ] **Native passkey login** (WebAuthn/FIDO2 on Android) +- [ ] **WebSocket streaming** (replace chunked HTTP for lower latency on mobile) +- [ ] **Usage/cost display** +- [ ] **User context / KI-Profil** + +### 5. App distribution + +- [ ] **Push notifications** (FCM) +- [ ] **Play Store / F-Droid listing** +- [ ] **Auto-update mechanism** + +### 6. Offline-cache enhancements (future iterations, see `OFFLINE_CACHE_ADD.md` §11) + +- [ ] **Stale-check enforcement** — use `cachedAt` to trigger background refresh when older than N minutes +- [ ] **Offline-queue for sends** — queue messages locally, send when network returns (needs conflict resolution) +- [ ] **Incremental sync** — only fetch changed conversations (needs server-side `since` parameter) +- [ ] **Media cache** — cache images/files locally (Coil or custom disk cache) +- [ ] **Full-text search** — FTS4/FTS5 over cached messages (Room supports this natively) +- [ ] **Attachment queries** — "show me all chats with images/PDFs" via Room DB + +## Dependencies + +```toml +# gradle/libs.versions.toml (key versions) +agp = "9.2.1" +kotlin = "2.2.10" +ksp = "2.2.10-2.0.2" +room = "2.8.4" +composeBom = "2026.02.01" +okhttp = "4.12.0" +kotlinxSerialization = "1.7.3" +securityCrypto = "1.1.0-alpha06" +``` + +## Tests + +**Unit tests** (`app/src/test/`): JVM-only, no emulator needed. + +- `SettingsViewModelTest` — state transitions, all 4 themes, appearance modes, name updates with store-less default +- `StreamConsumerTest` — batch + incremental parser, sentinel stripping, reasoning/sources/tools parsing, char-by-char edge cases +- `OklabTest` — round-trip conversion, interpolation midpoint, gradient generation (all green since Double-precision fix) +- `MappersTest` — `ConversationSummary` ↔ `ConversationEntity`, `StoredMessage` ↔ `MessageEntity`, round-trip integrity, attachment preservation +- `ConvertersTest` — `List` ↔ JSON round-trips, malformed JSON returns empty list, unknown fields tolerance via `ignoreUnknownKeys` +- `ColorPipelineTest` — all tokens in DisplayP3, approximate value ranges, no pure black/white in neutrals +- `SquircleShapeTest` — KaizenShapes token existence/distinctness, SquircleShape toString +- `ThemeRegistryTest` — 4 themes present, forId lookup, fromString parsing + fallback, distinct blob colors +- `ShadowStackTest` — layer counts per level, alpha range validation, blur/alpha progression + +## Constraints & gotchas + +- Local dev Mac has **no RustFS** (storage on server only); upload/storage flows fail locally with `ECONNREFUSED :9000`. +- Live OpenRouter key is encrypted in the DB (`api_keys`), `.env.local` is stale. +- Never hand-write migrations or fake timestamps — use `pnpm db:generate`. Always `git pull` before `docker build` on prod. +- App philosophy: the real driver is *feel*, not features; native Compose (no WebView). Backend complexity stays invisible. +- **Deploy gotcha (learned 2026-06-19):** `git pull` BEFORE `docker compose up --build`, else the image bakes stale code and the app gets 404/401 against endpoints that exist in the repo but aren't in the running build. +- Headless compile on Mac: `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin` — JDK 21 via Homebrew, Android SDK at `/opt/homebrew/share/android-commandlinetools`. +- **AGP 9.x + KSP:** requires `android.disallowKotlinSourceSets=false` in `gradle.properties`. +- **Room is a cache:** `fallbackToDestructiveMigration()` means the SQLite DB is wiped on schema changes. No data loss — server is source of truth, cache rebuilds on next fetch. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f67efae --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,970 @@ +# Kaizen Android — Visual Design + +> Native Android client for the Kaizen self-hosted AI chat platform. +> This document defines the visual language: color pipeline, type, surfaces, motion, and the rationale behind every choice. Read it before you change anything visible. + +--- + +## 0. TL;DR — the high-end stack + +If you skim nothing else, skim this. These are the six pillars that make Kaizen Android look like a 2026 product and not a 2019 Material app. + +| # | Pillar | Tech | +| - | ----------------------- | ----------------------------------------------------------------------------------------------------- | +| 1 | **Real glass** | `RenderEffect.createBlurEffect` (API 31+) on every floating surface. API 26–30 fallback: pre-blurred WebP. | +| 2 | **Wide-gamut color** | `ACONFIGURATION_COLOR_MODE_WIDE_COLOR_GAMUT` window mode. All brand tokens declared in DisplayP3. | +| 3 | **HDR highlights** | `COLOR_MODE_HDR` (API 34+) on capable displays (S25 Ultra, Pixel 8 Pro+). Orb specular peaks >1.0 nit. | +| 4 | **Squircle geometry** | iOS-style superellipse (`n ≈ 4.5`) as a Compose `Shape`. Replaces every `RoundedCornerShape`. | +| 5 | **Sensor-reactive** | Gyro/accelerometer drives orb specular direction + sidebar parallax. 60 Hz, gated by power state. | +| 6 | **Variable typography** | Inter Variable as a single TTF (~330 kB). Optical sizing for the hero headline. | + +If any of these regress, the design regresses. Treat them like load-bearing walls. + +--- + +## 1. Design intent + +Kaizen Android is the mobile companion to Kaizen Web — a self-hosted, multi-provider AI chat platform (`/Users/brunodeanoz/Kaizen/kaizen/CLAUDE.md`). The Android client is not a webview wrapper; it is a native Compose UI that **inherits the web's identity and elevates it** for a hardware-rich mobile target. + +### 1.1 What we are aiming for + +- **Premium, calm, intentional.** The user opens the app to think with an AI, not to be sold to. No marketing copy, no badges, no "Pro" prompts. The empty state is a greeting, a question, and an input. +- **Real liquid glass.** Surfaces actually refract what's behind them via `RenderEffect`. The blob layer is what they refract. +- **Apple-Intelligence-style hero**, not Google-Assistant-style. The orb is the visual anchor. The orb *is* Kaizen at every scale (hero → 28dp avatar → 200dp call-mode). +- **Brand color survives every theme switch.** Amber/Blau/Mono/Aurora are *user choices*, not wallpaper-derived. No `dynamicColor`. +- **Conversation first.** Once messages exist, chrome recedes. + +### 1.2 What we explicitly reject + +| Pattern | Why not | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| Gemini-style full-bleed pastel gradient + serif | Reads as a Google product. Steals identity. (See `ui-gemini.jpg`.) | +| Claude-style centered serif greeting on near-white | Bland. No depth, no brand color. Input bar reads as a separate sheet. (See `ui-claude.jpg`.) | +| Material You `dynamicColor = true` | Wallpaper-derived palettes erase the user's chosen Kaizen theme. We never enable it. | +| Bottom navigation bar | Chat is the single primary surface. Drawer instead. | +| Card-on-card "M3 expressive" layouts | One floating glass surface class. Let it breathe. | +| Suggestion-chip rows in the mobile empty state | Redundant with the mode pills directly above the input. Eats vertical real estate. (Web keeps them; mobile drops them.) | +| Lottie / framer-style scripted animations | Every motion is `AnimationSpec` + `Animatable`. Predictable, GPU-cheap, accessibility-aware. | +| Pure black `#000` backgrounds, pure white `#FFFFFF` text | Both create hard edges that fight the glass aesthetic. Off-black and off-white only. | + +### 1.3 Reference + +`ui-kaizen.jpg` (workspace root) is the canonical target for the empty state, with the modification that the four suggestion chips are **removed on mobile** (see §6.1). `ui-claude.jpg` and `ui-gemini.jpg` are *anti-references* — they show patterns we deliberately diverge from. + +--- + +## 2. Color pipeline + +Kaizen ships in **DisplayP3** end-to-end, with optional **HDR extended-range highlights** on capable displays. This is the single biggest visual upgrade over a stock Material app and it costs almost nothing if you set it up correctly. + +### 2.1 Why P3 (and why now) + +| Fact | Consequence | +| --------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| Every Pixel since the 4, every Samsung Galaxy S/Note since 2018, every iPhone since the 7 | Hardware supports the DisplayP3 gamut (≈25% wider than sRGB) | +| Android opts apps **out** of P3 by default — `Window.colorMode` defaults to `COLOR_MODE_DEFAULT` (sRGB) | Without our opt-in, the OS color-manages our amber back down to sRGB and the saturation is gone | +| Our brand color is `oklch(0.72 0.14 83)` — chroma `0.14` is **outside** sRGB at that hue | The web app already renders this in P3 via OKLCH. The Android app must match or it will look washed out next to the web | + +### 2.2 Opt-in + +```kotlin +// MainActivity.onCreate(), before setContent {} +window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT +if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // API 34+: enables extended-range (HDR) drawing on capable displays + window.colorMode = ActivityInfo.COLOR_MODE_HDR +} +``` + +Compose then draws into a P3-tagged surface. **All `Color(0xFF…)` constructors are interpreted as sRGB unless you say otherwise.** So we declare every brand color with an explicit color space: + +```kotlin +import androidx.compose.ui.graphics.colorspace.ColorSpaces + +val AmberP3 = Color(red = 0.878f, green = 0.635f, blue = 0.243f, + colorSpace = ColorSpaces.DisplayP3) +``` + +Hex-literal Color constructors are banned everywhere except `Color.Transparent` and pure debug tooling. Tokens live in `ui/theme/Color.kt` and are all float-component P3 colors. + +### 2.3 HDR extended-range highlights + +This is what makes the orb actually *glow* on your S25 Ultra (2,600 nits HDR peak) instead of just looking bright. + +```kotlin +// Brush for the orb's top-left specular +val orbSpecular = Brush.radialGradient( + colors = listOf( + Color(red = 4f, green = 4f, blue = 4f, // >1.0 = extended range + colorSpace = ColorSpaces.LinearExtendedSrgb), + Color.Transparent, + ), + center = Offset(...), radius = ..., +) +``` + +Compose canvases on API 34+ with `colorMode = COLOR_MODE_HDR` honor color components above `1.0` and route them to the display's HDR layer. On non-HDR displays the values clamp to `1.0` gracefully — no visual regression, just no HDR pop. + +We use this in exactly two places: + +1. **Hero orb specular** — top-left rim highlight, up to ~3.5× SDR white. +2. **Send button "armed" state** — when the user has typed text and the send icon turns full amber, the amber gains a faint HDR halo (peak ~1.8×). Subtle. The user notices it without naming it. + +We do **not** HDR-pop the headline, the model name, or anything text. HDR text is fatiguing and accessibility-hostile. + +### 2.4 Color tokens + +Defined in `ui/theme/Color.kt`. Each token has a **P3 float** form (canonical) and an **sRGB hex** form (fallback documentation, used for the Studio preview and design handoff). + +#### Brand (constant across all themes — they ARE the brand) + +| Token | P3 float (R, G, B) | sRGB ≈ | Use | +| ----------- | ---------------------------- | -------- | ------------------------------------------------ | +| `Amber` | `(0.878, 0.635, 0.243)` | `#E0A23E` | Orb, send button, focus ring, primary in dark | +| `AmberDeep` | `(0.784, 0.525, 0.165)` | `#C8862A` | Primary in light mode (better contrast on white) | +| `OnAmber` | `(0.106, 0.071, 0.024)` | `#1B1206` | Foreground on amber fills | + +> The amber survives dark/light. The theme selector (§3) swaps the *accent* palette around it. + +#### Light scheme — neutrals + +| Role | Token | P3 float | sRGB ≈ | +| ------------------ | --------------------- | ----------------------- | --------- | +| `background` | `LightBackground` | `(0.965, 0.969, 0.976)` | `#F6F7F9` | +| `surface` | `LightSurface` | `(1.000, 1.000, 1.000)` | `#FFFFFF` | +| `surfaceVariant` | `LightSurfaceVariant` | `(0.933, 0.941, 0.953)` | `#EEF0F3` | +| `onBackground` | `LightText` | `(0.102, 0.122, 0.161)` | `#1A1F29` | +| `onSurfaceVariant` | `LightMuted` | `(0.357, 0.392, 0.439)` | `#5B6470` | +| `outline` | `LightStroke` | `(0, 0, 0, α=0.08)` | — | + +#### Dark scheme — neutrals (Obsidian Steel-Blue) + +| Role | Token | P3 float | sRGB ≈ | +| ------------------ | ------------------ | ----------------------- | --------- | +| `background` | `Obsidian` | `(0.039, 0.055, 0.086)` | `#0A0E16` | +| `surface` | `ObsidianElevated` | `(0.078, 0.106, 0.153)` | `#141B27` | +| `surfaceVariant` | `ObsidianInput` | `(0.067, 0.094, 0.141)` | `#111824` | +| `onBackground` | `DarkText` | `(0.910, 0.922, 0.945)` | `#E8EBF1` | +| `onSurfaceVariant` | `DarkMuted` | `(0.545, 0.580, 0.643)` | `#8B94A4` | +| `outline` | `DarkStroke` | `(1, 1, 1, α=0.09)` | — | + +#### Banned + +- `Color(0xFF000000)` as background. We use `Obsidian` (an off-black with blue cast). +- `Color(0xFFFFFFFF)` as body text. We use `DarkText` (slightly cool, easier on the eye on OLED). +- Any color literal without an explicit `colorSpace` argument outside `Color.kt` itself. + +### 2.5 Color-space pitfalls + +These will bite you the first week. Document and lint for them. + +1. **`@Composable` images don't auto-convert.** A JPEG with an embedded sRGB profile rendered into a P3 canvas will look fine, but a JPEG with no profile will be assumed sRGB and may shift. Tag user-uploaded images with their declared profile when displaying. +2. **`Brush.linearGradient` interpolates in the canvas color space.** Good for us (P3 interpolation gives smoother gradients than sRGB), but means a gradient `from sRGB-red to sRGB-blue` rendered in P3 will pass through a slightly different midpoint. Define gradient stops in P3 directly. +3. **Screenshot tests** must capture in P3 too, otherwise CI fails on real-device output. Use `Bitmap.Config.RGBA_F16` + `ColorSpace.get(ColorSpace.Named.DISPLAY_P3)`. +4. **The IDE preview is sRGB.** Studio's `@Preview` does NOT show P3. Don't trust the preview for color decisions — verify on device. + +--- + +## 3. Theme system + +Kaizen Web ships four themes (`UiThemeId = "amber" | "blue" | "mono" | "aurora"`, see `kaizen/lib/theme/ui-themes.ts`). Android ships the **same four**, native, server-synced. + +### 3.1 Structure + +Each theme is a Kotlin object implementing `KaizenTheme`: + +```kotlin +data class KaizenAccentScheme( + val primary: Color, + val primaryDeep: Color, // light-mode primary + val onPrimary: Color, + val ring: Color, + val blob1: Color, + val blob2: Color, + val blob3: Color, + val blob4: Color, // theme-specific accent (teal for amber, peach for blue…) +) + +interface KaizenTheme { + val id: KaizenThemeId + val label: String + val light: KaizenAccentScheme + val dark: KaizenAccentScheme +} +``` + +Neutrals (`background`, `surface`, …) are theme-**independent** — same Obsidian / Light off-white across all four themes. Only the accent + the four background blob colors swap. This matches the web architecture (`globals.css` ships neutrals, `app/themes/.css` ships only the accent tokens). + +### 3.2 The four themes (accent + blobs) + +| Theme | Primary (dark) | Primary (light) | Blob 1 | Blob 2 | Blob 3 | Blob 4 (accent) | +| -------- | --------------------- | -------------------- | -------- | ------ | ------ | --------------- | +| Amber | Amber `(0.878,0.635,0.243)` | AmberDeep `(0.784,0.525,0.165)` | Amber | Blue | Indigo | Teal | +| Blau | Sky-500 P3 | Indigo-600 P3 | Blue | Indigo | Cyan | Amber (contrast)| +| Mono | Slate-100 | Slate-800 | Slate-300| Slate-400 | Slate-200 | Warm gray | +| Aurora | Magenta P3 | Violet-600 P3 | Magenta | Cyan | Violet | Lime (accent) | + +(Concrete P3 floats live in `ui/theme/themes/*.kt`. Each file ≤ 60 lines.) + +### 3.3 Server sync + +1. On launch, the app calls `GET /api/v1/me`. The response carries the user's `uiTheme` and `appearance` ("light" / "dark" / "system") — same values as the web reads from `localStorage.kaizen-ui-theme`. +2. Local storage: `EncryptedSharedPreferences` (`kaizen-ui-theme`, `kaizen-appearance`). This is the source of truth for offline launches. +3. When the user changes theme in the **app**, we PATCH `/api/v1/me`. The web app picks it up on next page load (via `next-intl` request cycle). +4. When the user changes theme in the **web**, the app picks it up on next foreground (re-fetch `/me` on `ON_RESUME`). + +### 3.4 Crossfading themes + +A theme switch animates over 600 ms with `animateColorAsState(spec = tween(600, easing = EaseSmooth))` on every color token. The accent and blob colors crossfade simultaneously. The neutrals don't change so the layout stays anchored. + +This matches the web's behavior — `globals.css` registers `--blob-1..4` via `@property { syntax: ""; inherits: true }` so they're CSS-Houdini-interpolatable. Same effect, different runtime. + +> **Critical detail:** the `inherits: true` bug from the web (`CLAUDE.md` line 333) doesn't apply here — Compose state propagates directly. But the analogous trap: `remember { … }` of a color *outside* of `CompositionLocal` will not recompose on theme change. Always read theme colors via `MaterialTheme.colorScheme.*` or `LocalKaizenTheme.current.*`, never cache. + +--- + +## 4. The Liquid-Glass surface + +This is the heart of the design. Everything floating — sidebar, input bar, model pill, suggestion chips, dropdowns, message input — is a `GlassSurface`. It is **real refractive blur**, not a translucent rectangle. + +### 4.1 Anatomy + +``` + ┌──────────────────────────────────────────────┐ + │ │ + │ ░ Blurred capture of what's behind ░ │ ← RenderEffect.createBlurEffect + background → │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ (24f, 24f, EDGE_CLAMP) + blob layer │ │ + │ ◤ Inset top highlight (1dp, white α 0.08) │ ← inner specular + │ │ + │ Content (icons / text / chip label) │ ← drawn over the blur + │ │ + │ ◣ Multi-layer drop shadow │ ← see §6 (Shadow stack) + └──────────────────────────────────────────────┘ + ↑ + 1 dp hairline (outline @ 60%) — final perimeter +``` + +### 4.2 Implementation + +```kotlin +@Composable +fun GlassSurface( + modifier: Modifier = Modifier, + shape: Shape = KaizenShapes.medium, + tint: Color = MaterialTheme.colorScheme.surface, + tintAlpha: Float = 0.72f, // see opacity tiers, §4.4 + blurRadius: Dp = 24.dp, + content: @Composable BoxScope.() -> Unit, +) { + val density = LocalDensity.current + val supportsBlur = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + Box( + modifier + .clip(shape) + .then( + if (supportsBlur) + Modifier.graphicsLayer { + renderEffect = RenderEffect.createBlurEffect( + with(density) { blurRadius.toPx() }, + with(density) { blurRadius.toPx() }, + Shader.TileMode.CLAMP, + ).asComposeRenderEffect() + } + else + Modifier // API 26–30 falls back to pre-blurred WebP (see §4.6) + ) + .drawBehind { + // The "frost" tint that gives the glass body + drawRect(tint.copy(alpha = tintAlpha)) + } + .innerTopHighlight() // 1dp inset white α=0.08 + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.6f), shape) + .squircleShadow(level = 2), // multi-layer shadow, see §6 + content = content, + ) +} +``` + +**Three things to internalize:** + +1. `graphicsLayer { renderEffect = … }` is the GPU primitive. It rasterizes the underlying composition into a layer, blurs that layer, and composites it back. Cost is proportional to the layer's pixel area, not the content complexity. Sidebar (~220 × 800 dp on phone) ≈ 0.6 ms per frame on a Snapdragon 8 Gen 3. Acceptable budget. +2. The `tint` is what gives the surface its color identity (Obsidian vs white). Without it the blur would just show a wash of whatever's behind, with no surface identity. Tint alpha controls how "milky" the glass is (see §4.4). +3. The blur is **not** a backdrop blur in the iOS sense — Android does not have a `Modifier.backdropBlur`. It blurs *the surface itself*, but because the surface is mostly transparent (`tintAlpha 0.72`), the blur of the content *behind* shows through. Same visual effect, different code path. The trick is that the content behind already drew before us (correct paint order). + +### 4.3 The capture layer + +For the blur to capture the blob layer behind, the blob layer **must paint into the same compositing context** that our blurred layer reads from. This means: + +- The blob `Canvas` composable is the first child of the root `Box`. +- It must NOT have `.graphicsLayer()` itself (no double-buffering — defeats the read-back). +- The glass surface is a sibling later in the same `Box`. + +Verified pattern in `chat/Background.kt`. If you ever wrap the blob layer in its own `GraphicsLayer` for animation perf, the glass surfaces will go gray. Don't. + +### 4.4 Opacity tiers (mirror of web `globals.css`) + +| Component | Tint alpha (light) | Tint alpha (dark) | Blur radius | Notes | +| ----------------------- | ------------------ | ----------------- | ----------- | ---------------------------------------------------- | +| Sidebar | 0.68 | 0.72 | 28 dp | Floats. Most translucent. The "frosted" reference. | +| Input bar | 0.74 | 0.75 | 24 dp | Slightly more body — reads as a real input surface. | +| Mode pill, model pill | 0.82 | 0.80 | 20 dp | Small surfaces need more body to read. | +| Suggestion chip (hero) | 0.90 | 0.85 | 16 dp | Tap targets. Must look solid. | +| Settings card | 0.95 | 0.94 | 16 dp | Reading surface. Near-opaque. | +| User bubble (amber-tinted) | 0.88 | 0.86 | 12 dp | Tinted glass via `tint = primary` (not surface). | +| Assistant bubble | — (no surface) | — (no surface) | — | Text on background. No card. | +| Dropdown / sheet | 0.85 | 0.82 | 32 dp | The deepest blur — pops the menu off the surface. | + +> These numbers are derived empirically on a Pixel 8 Pro and a Galaxy S25 Ultra. Re-tune if you ship to e-paper or low-bit-depth panels (none planned). + +### 4.5 Chromatic-aberration rim (`refraction = true`) + +For premium surfaces (hero orb, send button armed state, model pill on hover-equivalent), we add a one-pixel chromatic aberration at the rim. This is what makes glass look like *real* glass — light passing through a curved edge separates into R/G/B by tiny amounts. + +```kotlin +Modifier.drawWithCache { + val ringWidth = 1.dp.toPx() + onDrawWithContent { + drawContent() + // Three single-pixel offset strokes in pure R/G/B at the perimeter + drawOutline(outline, color = Color.Red.copy(alpha = 0.10f), + style = Stroke(ringWidth), translateX = -0.5f) + drawOutline(outline, color = Color.Green.copy(alpha = 0.06f), + style = Stroke(ringWidth)) + drawOutline(outline, color = Color.Blue.copy(alpha = 0.10f), + style = Stroke(ringWidth), translateX = +0.5f) + } +} +``` + +Used sparingly — on every glass surface this is noise. Only the orb and the armed send button. Toggleable via `KaizenTheme.LocalRefraction.current`. + +### 4.6 API 26–30 fallback + +`RenderEffect.createBlurEffect` requires API 31+. `minSdk` is 26. Fallback strategy: + +1. At build time, generate **pre-blurred snapshots** of the blob layer for each theme × mode (8 PNGs total, ~150 kB each at 540 × 1200, WebP at quality 85). These live in `res/drawable/blob_baked__.webp`. +2. On API 26–30, `Background.kt` renders the static WebP instead of the animated canvas (no drift animation — the static look is "old phone, less polish, still good"). +3. `GlassSurface` on those APIs skips `RenderEffect` and uses a higher tint alpha (+0.10 across all tiers) plus a slightly larger inner highlight to fake depth. + +Penalty: no animated blobs, no real blur. Acceptable for ~5% of installs (Android distribution stats Jun 2026: API 31+ ≈ 92%). Do not invest in a `RenderScript` blur fallback — `RenderScript` is deprecated and gone in API 35. + +--- + +## 5. Shape — squircles, not rounded rectangles + +The default `RoundedCornerShape` is a rectangle with quarter-circle corners. iOS, macOS, and modern web design (Tailwind 4 `rounded-squircle`) use the **continuous superellipse** — corners that ease into the straight edges with G2 continuity (curvature also matches, not just position). + +The difference is small at 4 dp radii. At 16+ dp it's the difference between "round rectangle" and "iPhone icon." + +### 5.1 The `KaizenSquircleShape` + +```kotlin +class SquircleShape( + private val radius: Dp, + private val smoothness: Float = 4.5f, // n in the superellipse equation; 2 = circle, ∞ = rectangle +) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val r = with(density) { radius.toPx() }.coerceAtMost(min(size.width, size.height) / 2f) + val path = Path().apply { + // Sample 32 points per corner along (x/r)^n + (y/r)^n = 1 + // Detailed implementation in ui/shape/Squircle.kt + … + } + return Outline.Generic(path) + } +} +``` + +### 5.2 Shape tokens (replace `Shapes`) + +| Token | Radius | Smoothness | Used for | +| -------------------- | ------ | ---------- | ----------------------------------------------------- | +| `KaizenShapes.xs` | 8 dp | 4.0 | Inline chips inside chips | +| `KaizenShapes.sm` | 12 dp | 4.2 | Dropdown items, inline action buttons | +| `KaizenShapes.md` | 16 dp | 4.5 | Sidebar surface, settings groups | +| `KaizenShapes.lg` | 24 dp | 5.0 | Hero suggestion chips, message bubbles | +| `KaizenShapes.xl` | 28 dp | 5.0 | Input bar, model pill, mode pills | +| `KaizenShapes.pill` | 999 dp | 4.5 | Send button, "Standard" badge | +| `KaizenShapes.circle`| — | — | Avatars, FAB, orb-bounding box | + +> Smoothness `n` from `4.0` to `5.0` is the sweet spot. Below `3.0` it's a rounded square. Above `7.0` it's visually indistinguishable from a rectangle. + +### 5.3 Liquid morph on press + +When the user presses any squircle (button, chip, pill), the shape briefly *liquefies* — radius increases by ~20% and smoothness drops to ~3.5 over 80 ms with `spring(dampingRatio = 0.55f)`. Released → returns to base. Same idea as iOS 26's liquid buttons. Implementation: animate `radius` and `smoothness` as `Animatable`, recompose the shape. + +Cost: shape recreation is allocation-heavy if done naively. Pool shapes by `(radius_bucket, smoothness_bucket)` with 0.5 dp / 0.1 buckets. ~30 shape instances total in steady state. + +--- + +## 6. Shadow stack + +Single drop-shadow looks 2014. Premium UI uses **layered shadows** (also called "long shadow" in design tools) — three to five overlapping shadows at increasing blur radii and decreasing opacity, summing to a soft, realistic falloff. + +### 6.1 Token + +```kotlin +data class ShadowLayer(val offsetY: Dp, val blur: Dp, val spread: Dp, val alpha: Float) + +data class ShadowStack(val layers: List) + +object KaizenShadows { + val level0 = ShadowStack(emptyList()) + + val level1 = ShadowStack(listOf( + ShadowLayer(1.dp, 2.dp, 0.dp, 0.04f), + ShadowLayer(1.dp, 4.dp, 0.dp, 0.02f), + )) + + val level2 = ShadowStack(listOf( + ShadowLayer(2.dp, 4.dp, 0.dp, 0.05f), + ShadowLayer(4.dp, 12.dp, 0.dp, 0.04f), + ShadowLayer(8.dp, 24.dp, 0.dp, 0.03f), + )) + + val level3 = ShadowStack(listOf( + ShadowLayer(2.dp, 4.dp, 0.dp, 0.06f), + ShadowLayer(8.dp, 16.dp, 0.dp, 0.05f), + ShadowLayer(16.dp,32.dp, 0.dp, 0.04f), + ShadowLayer(24.dp,48.dp, 0.dp, 0.03f), + )) + + val level4 = ShadowStack(listOf( // dialogs, call-mode overlay + ShadowLayer(4.dp, 8.dp, 0.dp, 0.07f), + ShadowLayer(12.dp,24.dp, 0.dp, 0.06f), + ShadowLayer(24.dp,48.dp, 0.dp, 0.05f), + ShadowLayer(48.dp,96.dp, 0.dp, 0.04f), + )) +} +``` + +Dark mode multiplies all alphas by ~1.8 — shadows need to be darker on dark surfaces to read at all. + +### 6.2 Implementation + +`Modifier.squircleShadow(level: ShadowStack)` draws each layer in order, before the content, using `Canvas.drawRoundRect` with a blurred paint. Five drawcalls per surface at level 3 — cheap. + +Compose's built-in `Modifier.shadow()` does NOT support custom shapes or stacks. We implement our own. Found in `ui/effect/Shadow.kt`. + +### 6.3 Inner-shadow / inset top highlight + +The companion to the drop shadow. Sells "light from above": + +```kotlin +Modifier.drawWithContent { + drawContent() + // 1dp inset top highlight, blurred 1dp + val highlightColor = if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.White.copy(alpha = 0.95f) + drawLine(highlightColor, Offset(0f, 1f), Offset(size.width, 1f), strokeWidth = 1.dp.toPx()) +} +``` + +Applied via `Modifier.innerTopHighlight()`. Together with the drop shadow, this is what makes glass look *lit* instead of *floating*. + +--- + +## 7. Sensor-reactive motion + +This is the single most "wow"-y interaction in the app. Tilt your phone slightly and the orb's specular highlight tracks your viewing angle as if it were a real glass sphere. The blob layer drifts a parallactically different amount than the foreground. Done right, the user notices but cannot name it — it just feels *alive*. + +### 7.1 The signal + +```kotlin +@Composable +fun rememberTiltState(): State { + val sensorManager = … + val rotationVector = sensorManager.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR) + val tilt = remember { mutableStateOf(Offset.Zero) } + + DisposableEffect(rotationVector) { + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + // Convert quaternion to pitch+roll, low-pass filter (α = 0.18) + // Clamp to ±15° → normalize to Offset(-1f..1f, -1f..1f) + tilt.value = filtered + } + … + } + sensorManager.registerListener(listener, rotationVector, + SensorManager.SENSOR_DELAY_GAME) // 50Hz + onDispose { sensorManager.unregisterListener(listener) } + } + return tilt +} +``` + +Uses **`TYPE_GAME_ROTATION_VECTOR`**, not the bare accelerometer. Game rotation vector is fused (gyro + accel + magnetometer) and pre-stabilized — gives stable 50 Hz delta with no drift correction needed. Lower power than full rotation vector. + +### 7.2 What reacts to tilt + +| Element | Effect | +| -------------------------- | ------------------------------------------------------------------------ | +| **Hero orb specular** | Top-left highlight moves opposite to tilt (`offset = -tilt * 8.dp`). Range ~±12 dp. | +| **Send button (armed)** | Subtle. `offset = -tilt * 2.dp`. Just enough to feel responsive. | +| **Blob layer** | Translates `tilt * 32.dp` (slower than foreground = parallax). | +| **Mesh-gradient highlight**| Subtly shifts color stops along tilt vector. ~3° hue rotation max. | +| **Conversation list rows** | NOT tilt-reactive. Lists must stay still for legibility. | +| **Message text** | NOT tilt-reactive. Same reason. | + +### 7.3 When to disable + +- `Settings.Global.ANIMATOR_DURATION_SCALE == 0f` (system "Remove animations") +- In-app "Reduce motion" setting (mirrors web `appearance.reduceMotion` once implemented) +- `PowerManager.isPowerSaveMode == true` — battery saver disables tilt +- Device tilted past ±25° (user is probably lying down or holding sideways — keeps the orb from snapping to a corner) +- `LifecycleOwner.lifecycle.currentState != STARTED` — never listen in background + +### 7.4 Frame-rate budget + +50 Hz sensor → state update → recompose only on `Modifier.graphicsLayer { translationX = … }` (which doesn't invalidate measure/layout). Total recomposition cost ≈ 0.2 ms per tilt event. Effectively free. + +--- + +## 8. Typography — Inter Variable + +System fonts are good. Inter is **better** at the sizes we use (16–32 sp body, 32–48 sp hero), and as a Variable Font it costs roughly the same as a single static weight. + +### 8.1 Why Inter + +- **Designed for screens.** Tall x-height, open apertures, large counters — readable at small sizes on low-DPI fallback devices. +- **Variable axes:** `wght` (100–900) and `opsz` (14–32) plus subtle `slnt`. We use `wght` everywhere and `opsz` on the hero headline. +- **Open license** (SIL OFL). Ship freely. +- **Matches the web app's intent** — the web defaults to system, but switching it to Inter has been on the backlog. The app can lead. + +### 8.2 Shipping + +- One file: `res/font/inter_variable.ttf` (~330 kB compressed in APK). +- Declared as a `FontFamily` with variation settings: + +```kotlin +val InterVariable = FontFamily( + Font(R.font.inter_variable, weight = FontWeight.W400), + // Compose 1.7+ supports variation settings; pre-1.7 falls back to weight axis +) +``` + +- On API 26+ all Variable Font axes are supported by Android's font renderer. No fallback needed. + +### 8.3 Type scale + +| Role | Size | Weight (`wght`) | Optical (`opsz`) | Line height | Tracking | Used for | +| --------------- | ----- | --------------- | ---------------- | ----------- | --------- | ------------------------------------- | +| `displayLarge` | 32 sp | 640 | 32 | 38 sp | −1.0% | Hero headline ("Was liegt an?") | +| `displayMedium` | 24 sp | 600 | 24 | 30 sp | −0.5% | Settings section titles | +| `titleLarge` | 20 sp | 560 | 20 | 26 sp | 0 | Card titles | +| `titleMedium` | 16 sp | 540 | 16 | 22 sp | +0.3% | Model name, sidebar items | +| `bodyLarge` | 16 sp | 420 | 16 | 24 sp | +0.5% | Chat message body | +| `bodyMedium` | 14 sp | 420 | 14 | 20 sp | +0.8% | Secondary text, settings descriptions | +| `labelLarge` | 14 sp | 540 | 14 | 20 sp | +1.0% | Buttons, chip labels | +| `labelMedium` | 12 sp | 540 | 14 | 16 sp | +2.0% | Pills ("Standard") | +| `labelSmall` | 11 sp | 520 | 14 | 16 sp | +3.0% | Timestamps, date subtitle | +| `code` | 14 sp | 460 (mono) | — | 22 sp | 0 | Code blocks (JetBrains Mono Variable) | + +Two principles: + +1. **Weight tracks size.** At small sizes Inter needs slightly heavier weight (520–560) to stay crisp. Hero size uses `640` not `700` — `700` is too display-y at 32 sp on glass. +2. **Tracking is positive at small sizes, negative at large.** Inter ships with default tracking that's optimized for ~16 sp body. We loosen below 14 sp and tighten above 24 sp. + +### 8.4 Optical sizing (`opsz`) + +The big trick. At display sizes (24–32 sp) we set `opsz = 24–32`, which makes Inter pick its "Display" outlines — slightly narrower, tighter, more elegant. At body sizes (14–16 sp) we set `opsz = 14–16` → "Text" outlines: wider, more open, more legible. + +This is automatic if you pass `FontVariation.Setting("opsz", size.toFloat())` to the `Font(...)` definition. Compose 1.7+ supports this directly. + +### 8.5 Mono for code + +JetBrains Mono Variable (~280 kB) for inline `code` and code blocks. Tabular figures, ligatures off (programmer aesthetics divide on this — off is the conservative default and we can flip per-user later). + +--- + +## 9. The Kaizen Orb (premium tier) + +The orb is the brand mark, the empty-state hero, the assistant avatar, and the call-mode focal point. It is the single most-engineered visual in the app. Here is the full premium recipe. + +### 9.1 Sizes & use + +| Size | Where | Premium features active | +| --------- | -------------------------------------- | --------------------------------------------- | +| 96–160 dp | Hero (`EmptyHero`) | All: HDR specular, tilt, breath, refraction | +| 28 dp | Assistant avatar in message list | Static recipe (no real blur — too small) | +| 28 dp + streaming | Avatar while answer streams | Fast breath + triple-shadow glow | +| 200 dp | Call-mode overlay | All + amplitude-reactive scale (mic input) | + +### 9.2 The seven layers (hero) + +Top to bottom (paint order): + +1. **Drop-shadow halo** — `ShadowStack` level 3 + an additional `primary @ 30%` blur at 40 dp. Sells "this thing is hovering." +2. **Outer wrapper** — owns the `breath` animation (1.0 → 1.025 → 1.0, 7 s sinusoidal). On streaming: faster (1.8 s, scale 1.0 → 1.12). +3. **Outer rim — chromatic aberration** — §4.5 recipe. Three 1px R/G/B strokes. +4. **Inner sphere — refractive blur** — `RenderEffect.createBlurEffect(28f, 28f, CLAMP)` reading the blob layer. THIS is what makes the orb a glass marble rather than a painted ball. The blob colors visibly shift as the orb moves (or the user tilts the device). +5. **Internal sweep gradient** — `Brush.sweepGradient(0° = primary @ 60%, 90° = primary-deep @ 80%, 180° = primary @ 60%, 270° = white @ 30%)`. Rotates with `tilt.x * 30°` — gives the orb internal chromatic depth that responds to viewing angle. +6. **Specular highlight (top-left)** — radial gradient, `LinearExtendedSrgb(4, 4, 4) → transparent`, ~30% of orb radius, center offset by `-tilt * 8.dp`. **This is the HDR pop.** On a 2,600-nit panel this is what makes the user say "huh, why is that brighter than the rest of my screen." +7. **Bottom inner glow** — radial gradient, `primary @ 40% → transparent`, ~60% of orb radius, anchored to bottom-center. Suggests internal light source. + +### 9.3 Streaming variant + +When `isStreaming == true`, the 28 dp avatar swaps state. Visually critical that the user can tell "Kaizen is thinking" without reading text: + +- Breath → `hero-orb-breath-fast` (1.8 s, scale 1.0 → 1.12) +- Triple-stacked shadow: `primary @ 30/20/10%` at blur `8/16/24 dp` +- The internal sweep gradient rotates continuously at 90°/s (only in streaming state — static otherwise) + +When streaming ends, the orb returns to the calm breath over 400 ms with `spring(dampingRatio = 0.6f)`. + +### 9.4 Call mode (200 dp) + +In `CallView`, the orb is the entire UI. Three additional behaviors: + +- **Amplitude scaling.** Mic input RMS over the last 100 ms drives `scale = 1.0 + amplitude * 0.3` (capped at 1.3). When the user speaks, the orb breathes with their voice. +- **Color temperature shift.** While the TTS plays back (assistant speaking), the orb shifts ~10° towards warm. While the user speaks, ~10° towards cool. Subliminal: warm = listening, cool = speaking. Same idea as Siri's modes but quieter. +- **Pulse on tool-call.** When the assistant fires a tool (web search, etc.), a one-shot pulse — single scale 1.0 → 1.15 → 1.0 over 250 ms. Tells the user "something happened" without text. + +--- + +## 10. Animation system + +Every motion in the app uses the same easing/spec primitives. Defined in `ui/motion/Motion.kt`. + +### 10.1 Easing tokens + +```kotlin +val EaseSpring = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) // overshoot, for entrances +val EaseSmooth = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) // expo-out, for transitions +val EaseStandard = FastOutSlowInEasing // system-aligned +val EaseEmphasized = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) // M3 emphasized + +val SpringSnappy = spring(dampingRatio = 0.55f, stiffness = 800f) +val SpringDefault = spring(dampingRatio = 0.75f, stiffness = 400f) +val SpringGentle = spring(dampingRatio = 0.85f, stiffness = 200f) +``` + +### 10.2 Spec assignments + +| Use case | Spec | +| ------------------------- | ------------------------------------------------------------ | +| Bubble entrance | `tween(350ms, easing = EaseSpring)` | +| Page transition (slide+fade) | `tween(280ms, easing = EaseEmphasized)` | +| Dropdown open | `tween(150ms, easing = EaseSmooth)` | +| Sidebar slide | `spring(dampingRatio = 0.85f, stiffness = 300f)` | +| Theme color crossfade | `tween(600ms, easing = EaseSmooth)` | +| Orb breath | `infiniteRepeatable(tween(7000ms, EaseStandard), reverseDirection)` | +| Orb breath (streaming) | `infiniteRepeatable(tween(1800ms, EaseStandard), reverseDirection)` | +| Squircle press | `SpringSnappy` | +| Tilt response (sensor) | None — direct binding. Sensor's own low-pass handles smoothing. | +| Thinking dots | `infiniteRepeatable(tween(1000ms, LinearEasing))` × 3 with 160 ms phase offsets | + +### 10.3 Shared-element transitions + +Compose 1.7+ supports `SharedTransitionScope` with `Modifier.sharedElement(...)`. We use it for two transitions: + +1. **Conversation list row → chat screen.** The conversation title in the sidebar shared-element-transitions into the header. Feels like "tapping into" the chat. +2. **Empty-state orb → first assistant avatar.** When the user sends their first message, the hero orb shrinks from 120 dp to 28 dp and docks left of the first assistant bubble. 600 ms with `EaseEmphasized`. Conceptually: "the thing you were looking at is now answering you." This is the single most magical moment in the app. + +### 10.4 Haptics (`haptics/Haptics.kt`) + +Sparingly. Overuse desensitizes. + +| Trigger | Effect | +| -------------------------------- | -------------------------------------------------------- | +| Send message | `HapticFeedbackConstants.CONFIRM` (API 30+) / `VIRTUAL_KEY` | +| Long-press conversation row | `LONG_PRESS` | +| Toggle mode pill | `SEGMENT_TICK` (API 30+) / `CLOCK_TICK` | +| Theme switch | `GESTURE_END` once when the crossfade completes | +| Stream complete (>5s wait) | `GESTURE_END` | +| Error | `REJECT` (API 30+) / double `VIRTUAL_KEY` | + +--- + +## 11. Screen-by-screen + +### 11.1 Empty state / new chat (`EmptyHero`) — mobile + +**No suggestion chips.** Different from `ui-kaizen.jpg`. The chips are redundant with the mode pills above the input (same function: "set mode before typing"). On a phone with a keyboard open, the chip row eats 20% of the visible height for no gain. Web keeps them (different ergonomics, hover, more room). Mobile drops them. + +``` +┌─────────────────────────────────────────┐ +│ ☰ ⌃ Gemini 3.5 Flash ▾ │ +│ │ +│ │ +│ │ +│ ●●●●●●●● │ +│ ● ● │ +│ ● ORB ● (120 dp) │ +│ ● ● │ +│ ●●●●●●●● │ +│ │ +│ Guten Tag, Bruno │ +│ │ +│ Was liegt an? │ +│ │ +│ So., 21. Juni · 13:34 │ +│ │ +│ │ +│ │ ← intentional whitespace +│ │ +│ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ⊕ Standard Sampling Bild Suchen │ │ ← Mode pills (horizontal scroll) +│ └─────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ⊕ Nachricht eingeben … ☎ ↑ │ │ ← Input bar +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +**Layout details** + +- Vertical alignment: `Top` with `paddingTop = max(32.dp, 8% screenHeight)`. Not `Center`. Centered drifts up when the keyboard opens. +- Greeting: time-of-day-based (Morgen / Tag / Abend, boundaries 5/12/18). First name from `/api/v1/me`. Falls back to bare "Guten Tag" if name missing. +- Subtitle: localized short-date + time, live every minute, `tabular-nums`. +- All three sections (orb / greeting / subtitle) animate in staggered: `fadeIn + slideInVertically` with delays 0 / 100 / 200 ms. Total entrance time 700 ms. + +### 11.2 Active conversation (`ChatScreen`) + +``` +┌─────────────────────────────────────────┐ +│ ☰ Gemini 3.5 Flash ▾ ⋯ │ +├─────────────────────────────────────────┤ +│ │ +│ ╭─────────────────────╮│ +│ │ Was ist Kaizen? ││ ← User bubble (amber-tinted glass) +│ ╰─────────────────────╯│ +│ │ +│ ◉ Kaizen ist eine japanische │ ← Assistant: orb + plain markdown +│ Philosophie der kontinu- │ no surface around the text +│ ierlichen Verbesserung … │ +│ │ +│ ▸ Gedankengang (3s) │ ← Collapsible ThinkingBlock +│ │ +│ ╭─────────────────────╮│ +│ │ Erzähl mir mehr ││ +│ ╰─────────────────────╯│ +│ │ +│ ◉ ● ● ● │ ← Streaming dots before first token +│ │ +├─────────────────────────────────────────┤ +│ [mode pills row] │ +│ ⊕ Erzähl mir mehr … ☎ ▣ │ ← Stop while streaming +└─────────────────────────────────────────┘ +``` + +- Autoscroll only if user is within 80 dp of the bottom. Otherwise show a "↓ Neue Nachricht" pill bottom-right. +- Bubble entrance: `bubble-in` (translateY 8 dp + scale 0.98, 350 ms spring overshoot). Stable `key={node.id}` ensures it fires once per insert. +- ThinkingBlock auto-opens during streaming, auto-collapses 300 ms after completion. Tappable to re-open. + +### 11.3 Sidebar (`Sidebar.kt`) + +`ModalNavigationDrawer` on phones, permanent rail on ≥600 dp width. + +``` +┌──────────────────────┐ +│ ⌕ Suchen │ +├──────────────────────┤ +│ + Neuer Chat │ +├──────────────────────┤ +│ Heute │ +│ ▸ Was ist Kaizen? │ ← active row: primary @ 8% bg tint +│ Python sorting │ +│ UI Design … │ +│ │ +│ Gestern │ +│ Docker setup │ +│ ──── │ +│ ▸ Älter │ +├──────────────────────┤ +│ 👤 Bruno ⋯ │ ← UserCard: tap → settings menu +└──────────────────────┘ +``` + +- Whole sidebar is one `GlassSurface` tier 1 (most translucent). 8 dp margin all sides — "floating drawer" not "edge-attached panel." +- Long-press → inline action sheet (Rename / Lock / Delete). 250 ms hold, haptic on commit. +- Search is in-memory against the offline Room cache. Never hits the server. + +### 11.4 Settings (`SettingsScreen`) + +Sectioned, single-screen-per-section, navigation via the sidebar (replaces conversation list when route is `settings/*`). Each section is a stack of `SettingsCard`s (opacity tier 6, near-opaque) with `Field` rows. + +Mirrors the web `components/settings/section-ui.tsx` Card + Field primitives. + +--- + +## 12. Component library + +### 12.1 Buttons + +| Variant | Composable | When to use | +| --------------- | ----------------------- | ------------------------------------------------- | +| Primary (FAB) | `KaizenSendButton` | Send message (amber pill-circle, 44 dp) | +| Primary (text) | `KaizenButton` | "Speichern", "Anmelden" — full-width in dialogs | +| Secondary | `KaizenOutlinedButton` | "Abbrechen", "Verwerfen" | +| Tertiary | `KaizenTextButton` | Inline "Mehr anzeigen", "Bearbeiten" | +| Icon | `KaizenIconButton` | Toolbar icons, chip close X | +| Pill | `ModePill`, `OptionPill`| Mode selector, model selector, image options | + +All buttons: +- Min tap 44 dp. +- Pressed: `scale 0.94` via `SpringSnappy`. No M3 ripple over glass — the scale + opacity tweak is the feedback. +- Disabled: `alpha = 0.4`, no scale, no haptic. + +### 12.2 Inputs + +- `ChatInputBar` — Material 3 `TextField` with custom decoration. `GlassSurface` container, no underline, multi-line up to 6 rows. +- `KaizenTextField` — Settings dialog text inputs. Outlined Material 3 with brand-overridden colors. +- Placeholder color: `onSurfaceVariant @ 60%` (M3 default is too prominent on glass). + +### 12.3 Chips + +- `SuggestionChip` (hero) — `GlassSurface` tier 4, icon left, label right, `KaizenShapes.lg`, 14 sp label. +- `AttachmentChip` (input bar) — file-type icon + filename + close X, `KaizenShapes.sm`. +- `QuoteChip` (input bar) — quote icon + `line-clamp-3` text + close X, displayed above attachments. From "Ask about selection." + +### 12.4 Lists + +- Conversation row: 48 dp height, 16 dp horizontal padding, label + optional lock badge. +- Settings field row: 56 dp height. +- Long-press: 250 ms hold → `LONG_PRESS` haptic → contextual action sheet. + +### 12.5 Dropdowns / sheets + +Portal-style (`Popup`) with the entrance pattern: +- `fadeIn(150ms) + scaleIn(0.95, anchor) + slideInVertically(initialOffsetY = { it / 8 })` +- Matches web `animate-in fade-in zoom-in-95 slide-in-from-top-2`. + +Bottom sheets on phones use `ModalBottomSheet` with `GlassSurface` as the container shape. + +### 12.6 Snackbar (`KaizenSnackbar`) + +No native `Toast` (looks wrong on glass). `GlassSurface` tier 3, bottom-anchored, 4 s auto-dismiss, manual close. Same recipe as the web app's `createPortal` toast in `users-card.tsx`. + +--- + +## 13. Dark mode + +Dark mode is not "recolor light mode." It is a **different spatial system**. + +### 13.1 What changes + +- Background luminance: L=0.96 → L=0.10. +- Blob `BlendMode`: normal → `BlendMode.Plus` (additive — makes blobs glow on dark). +- Glass top highlight inset: white α 0.95 → white α 0.08 (stronger on dark to fake light). +- Shadow alpha: ×1.8 (shadows need to be darker on dark surfaces to read). +- Code-block highlight theme: `github` → `github-dark-dimmed`. +- Brand amber: `AmberDeep` → `Amber` (pure amber has more weight on Obsidian). + +### 13.2 What does NOT change + +- Orb recipe (a sphere is a sphere — only the background reflection differs). +- Theme identity (Amber stays Amber). +- Shapes, sizes, line heights, tracking. +- Layout — never reflow on mode change. + +### 13.3 Switching + +System default by default. User override in Settings → Erscheinungsbild (mirrors web `appearance.design`). Crossfade 600 ms with `tween(EaseSmooth)` on every color. Status bar + nav bar adopt the new appearance via `WindowCompat.getInsetsController(...).isAppearanceLightStatusBars`. + +--- + +## 14. Accessibility + +| Requirement | How we meet it | +| ---------------------------------------- | ---------------------------------------------------------------- | +| Touch target ≥ 44 dp | All interactive components. | +| Contrast ≥ 4.5:1 body text | `LightText` on `LightBackground` = 13.2:1; `DarkText` on `Obsidian` = 14.8:1. | +| Contrast ≥ 3:1 large text | Hero headline passes both modes. | +| Contrast ≥ 3:1 UI components | `AmberDeep` on white = 3.1:1; `Amber` on Obsidian = 8.4:1. | +| `contentDescription` on icon buttons | Lint via `composeRules` (`MissingContentDescription`). | +| Respect `fontScale` | All text in `sp`, layouts in `dp`. Verified up to 1.3×. | +| Respect reduced motion | `Settings.Global.ANIMATOR_DURATION_SCALE == 0f` → instant transitions, static blobs, no tilt, no orb breath. | +| TalkBack labels for the orb | `"Kaizen"` (idle), `"Kaizen denkt nach"` (streaming). | +| HDR opt-out | `Settings.Global.HDR_TONE_MAPPING_DISABLED` (where applicable) → fall back to SDR specular. | +| Keyboard nav (external KB) | Verified focus order: Login, Settings, Chat input. | +| RTL | Out of scope (de/en only). | + +--- + +## 15. Internationalization + +- de (default), en. Same as web (`messages/de.json`, `messages/en.json`). +- Strings: `res/values/strings.xml` (en) + `res/values-de/strings.xml` (de). +- Date/time: `java.time.format.DateTimeFormatter.ofLocalizedDate(MEDIUM)` with `Locale.getDefault()`. +- Greeting boundaries localized — English uses Morning/Afternoon/Evening. + +--- + +## 16. Performance budget + +If we lose any of these, the design has degraded. Wire them into Macrobench in CI. + +| Metric | Budget (S25 Ultra, default theme) | Budget (Pixel 6a, low-end) | +| ------------------------------------------------- | --------------------------------- | --------------------------- | +| Cold start (Activity creation → first frame) | ≤ 600 ms | ≤ 1100 ms | +| Frame time, idle empty state with blobs | ≤ 4 ms (sustained 120 fps) | ≤ 14 ms (60 fps) | +| Frame time, scrolling conversation (50 msgs) | ≤ 6 ms | ≤ 16 ms | +| Frame time, theme switch crossfade peak | ≤ 9 ms | ≤ 22 ms | +| Memory, idle on chat screen | ≤ 130 MB | ≤ 95 MB | +| Battery drain, 10 min foreground idle (chat open) | ≤ 3% (HDR active) / ≤ 2% (SDR) | ≤ 2.5% | + +Run via Macrobenchmark with `androidx.benchmark.compilation.mode=Partial` (Baseline Profiles). Baseline Profiles are mandatory — without them cold start nearly doubles. + +--- + +## 17. Asset inventory + +`app/src/main/res/`: + +| Asset | Format | Notes | +| ---------------------------------- | -------------------- | --------------------------------------- | +| `mipmap-*/ic_launcher` | Adaptive icon XML | Orb on amber background | +| `mipmap-anydpi/ic_launcher_round` | Adaptive icon | API 25+ round launcher | +| `drawable/noise_64.png` | 64×64 PNG, alpha | Film-grain overlay (§4.1) | +| `drawable/blob_baked__.webp` | Pre-blurred | API 26–30 blob layer fallback (×8 total) | +| `font/inter_variable.ttf` | Variable Font | ~330 kB | +| `font/jetbrains_mono_variable.ttf` | Variable Font | ~280 kB, code blocks | + +No Lottie, no SVG at runtime (Compose `ImageVector` instead), no remote font fetch. + +--- + +## 18. Open design questions + +Capture decisions here as they're made. + +1. **Sources / citation rendering on mobile.** Web uses an inline collapsed `SourcesBlock`. Mobile competes for vertical room. Lean: a single "5 Quellen" pill that opens a bottom sheet. +2. **Tablet layout** — permanent sidebar at ≥600 dp width, or stays modal? Currently modal everywhere. Revisit during foldable testing. +3. **Image-gen viewer** — full-screen pinch-zoom (planned) vs inline expandable. +4. **Call-mode landscape** — portrait-only initially. Landscape later. +5. **JetBrains Mono ligatures** — off by default. Per-user toggle in Settings → Erscheinungsbild later? +6. **Refraction (chromatic aberration) global toggle.** Currently only on orb + armed send button. Should there be a "subtle / standard / off" setting? + +--- + +## 19. Implementation checklist (in order) + +For someone picking up the project tomorrow: + +1. **Color pipeline.** Convert all `Color(0xFF…)` in `ui/theme/Color.kt` to P3 float form. Add `window.colorMode = COLOR_MODE_WIDE_COLOR_GAMUT` (and `HDR` on API 34+) in `MainActivity.onCreate`. Test on a P3 device — amber should look visibly more saturated than before. +2. **Squircle shape.** Implement `SquircleShape` in `ui/shape/Squircle.kt`. Replace all `RoundedCornerShape(...)` usages. +3. **Inter Variable.** Drop `inter_variable.ttf` into `res/font/`. Replace `FontFamily.Default` in `Type.kt`. Wire `opsz` axis per size. +4. **Shadow stack.** Implement `Modifier.squircleShadow(level)` in `ui/effect/Shadow.kt`. Replace all `Modifier.shadow(...)` usages. +5. **GlassSurface composable** with `RenderEffect` + API fallback. Migrate all "card-like" Compose surfaces to it. +6. **Background blobs** with animated `Canvas` + WebP fallback (`chat/Background.kt`). +7. **Orb premium layers** — refractive blur, sweep gradient, HDR specular, tilt-bound highlight. +8. **Sensor tilt state** — `rememberTiltState()` plus the four consumers (orb, send-armed, blobs, mesh). +9. **Theme system** — four `KaizenTheme` objects, server sync via `/api/v1/me`, crossfade on switch. +10. **Shared element transitions** — sidebar row → header, hero orb → first assistant avatar. +11. **Baseline Profile** generation — wire into release build. +12. **Macrobenchmark** suite covering §16 budgets. + +--- + +## 20. Changelog + +| Date | Change | +| ---------- | -------------------------------------------------------------------------------------------- | +| 2026-06-21 | Initial draft — tokens, components, motion. | +| 2026-06-21 | v2 — full premium spec. P3 + HDR color pipeline, real RenderEffect glass, squircle shapes, Inter Variable, sensor-reactive motion, four-theme server sync. Mobile empty state drops suggestion chips. | diff --git a/app/src/main/java/dev/kaizen/app/MainActivity.kt b/app/src/main/java/dev/kaizen/app/MainActivity.kt index 1d66d62..8c9cfcc 100644 --- a/app/src/main/java/dev/kaizen/app/MainActivity.kt +++ b/app/src/main/java/dev/kaizen/app/MainActivity.kt @@ -7,6 +7,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent +import androidx.compose.runtime.collectAsState import androidx.activity.enableEdgeToEdge import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.chat.ChatScreen @@ -14,6 +15,7 @@ import dev.kaizen.app.chat.ChatViewModel import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.settings.SettingsViewModel +import dev.kaizen.app.settings.AppAppearance import dev.kaizen.app.ui.theme.KaizenTheme class MainActivity : ComponentActivity() { @@ -29,8 +31,9 @@ class MainActivity : ComponentActivity() { window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT } - val settingsViewModel = SettingsViewModel() - val sessionViewModel = SessionViewModel(SecureStore(applicationContext)) + val secureStore = SecureStore(applicationContext) + val settingsViewModel = SettingsViewModel(secureStore) + val sessionViewModel = SessionViewModel(secureStore) val chatViewModel = ChatViewModel(applicationContext) // auto() picks light/dark system-bar icons based on the system theme @@ -39,7 +42,14 @@ class MainActivity : ComponentActivity() { navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), ) setContent { - KaizenTheme { + val themeId = settingsViewModel.selectedTheme.collectAsState() + val appearance = settingsViewModel.appearanceMode.collectAsState() + val darkOverride = when (appearance.value) { + AppAppearance.Light -> false + AppAppearance.Dark -> true + AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme() + } + KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) { // Snapshot-state routing: logging in/out flips session.config and // recomposes this branch — no NavHost needed for two top-level states. if (sessionViewModel.isLoggedIn) { diff --git a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt index b22c889..1cadcf7 100644 --- a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt +++ b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt @@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape +import dev.kaizen.app.ui.shape.KaizenShapes import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -52,6 +52,8 @@ import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.ui.theme.Amber import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.OnAmber +import androidx.compose.ui.res.stringResource +import dev.kaizen.app.R import kotlinx.coroutines.launch /** @@ -64,6 +66,7 @@ import kotlinx.coroutines.launch fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) { val cs = MaterialTheme.colorScheme val scope = rememberCoroutineScope() + val ctx = androidx.compose.ui.platform.LocalContext.current var baseUrl by remember { mutableStateOf(DEFAULT_BASE_URL) } var email by remember { mutableStateOf("") } @@ -81,9 +84,9 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) { scope.launch { when (val result = session.login(baseUrl, email, password, deviceName)) { is LoginResult.Success -> Unit // session.config flips → MainActivity routes to chat - LoginResult.InvalidCredentials -> error = "E-Mail oder Passwort ist falsch." - LoginResult.RateLimited -> error = "Zu viele Versuche. Bitte kurz warten." - is LoginResult.Error -> error = "Verbindung fehlgeschlagen. Server-Adresse prüfen." + LoginResult.InvalidCredentials -> error = ctx.getString(R.string.login_error_credentials) + LoginResult.RateLimited -> error = ctx.getString(R.string.login_error_rate_limited) + is LoginResult.Error -> error = ctx.getString(R.string.login_error_connection) } loading = false } @@ -102,10 +105,10 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) { Spacer(Modifier.size(64.dp)) KaizenOrb(96.dp) Spacer(Modifier.size(24.dp)) - Text("Willkommen", color = cs.onBackground, fontSize = 30.sp, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.login_welcome), color = cs.onBackground, fontSize = 30.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.size(6.dp)) Text( - "Melde dich bei deiner Kaizen-Instanz an", + stringResource(R.string.login_subtitle), color = cs.onSurfaceVariant.copy(alpha = 0.8f), fontSize = 14.sp, ) @@ -115,7 +118,7 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) { GlassField( value = baseUrl, onValueChange = { baseUrl = it; error = null }, - placeholder = "https://deine-instanz.de", + placeholder = stringResource(R.string.login_url_placeholder), keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next, ) @@ -123,7 +126,7 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) { GlassField( value = email, onValueChange = { email = it; error = null }, - placeholder = "E-Mail", + placeholder = stringResource(R.string.login_email_placeholder), keyboardType = KeyboardType.Email, imeAction = ImeAction.Next, ) @@ -131,7 +134,7 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) { GlassField( value = password, onValueChange = { password = it; error = null }, - placeholder = "Passwort", + placeholder = stringResource(R.string.login_password_placeholder), keyboardType = KeyboardType.Password, imeAction = ImeAction.Done, password = true, @@ -178,10 +181,10 @@ private fun GlassField( Modifier .fillMaxWidth() .widthIn(max = 420.dp) - .shadow(elevation = 6.dp, shape = RoundedCornerShape(20.dp), clip = false) - .clip(RoundedCornerShape(20.dp)) + .shadow(elevation = 6.dp, shape = KaizenShapes.lg, clip = false) + .clip(KaizenShapes.lg) .background(glassBg) - .border(1.2.dp, glassBorder, RoundedCornerShape(20.dp)) + .border(1.2.dp, glassBorder, KaizenShapes.lg) .heightIn(min = 54.dp) .padding(horizontal = 18.dp, vertical = 16.dp), contentAlignment = Alignment.CenterStart, @@ -216,8 +219,8 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit .fillMaxWidth() .widthIn(max = 420.dp) .heightIn(min = 54.dp) - .shadow(elevation = 8.dp, shape = RoundedCornerShape(20.dp), clip = false) - .clip(RoundedCornerShape(20.dp)) + .shadow(elevation = 8.dp, shape = KaizenShapes.lg, clip = false) + .clip(KaizenShapes.lg) .then(fill) .clickable(enabled = enabled, onClick = onClick) .padding(vertical = 16.dp), @@ -227,7 +230,7 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit CircularProgressIndicator(color = OnAmber, strokeWidth = 2.4.dp, modifier = Modifier.size(22.dp)) } else { Text( - "Anmelden", + stringResource(R.string.login_submit), color = if (enabled) OnAmber else cs.onSurfaceVariant, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, 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 e83d7ad..42b29bc 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Background.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Background.kt @@ -1,23 +1,14 @@ 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.LinearEasing import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring 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.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box 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.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect 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.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment @@ -44,13 +31,14 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke 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 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.AmberDeep import dev.kaizen.app.ui.theme.BlobAmber -import dev.kaizen.app.ui.theme.BlobBlue +import dev.kaizen.app.ui.theme.BlobCoral import dev.kaizen.app.ui.theme.BlobTeal import dev.kaizen.app.ui.theme.DITHER_ALPHA import dev.kaizen.app.ui.theme.rememberDitherBrush @@ -81,7 +69,7 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) { }, ) { Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor) - Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor) + Blob(BlobCoral, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor) Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor) // Vignette: darken the edges so the center comes forward (dark mode only). if (dark) { @@ -132,58 +120,17 @@ fun KaizenOrb( initialValue = 1f, targetValue = if (streaming || recording) 1.05f else 1.025f, 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, ), 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 - // --- Gyroscope / Accel-based Holographic Parallax Offset --- - val context = LocalContext.current - var rawTiltX by remember { mutableStateOf(0f) } - 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" - ) + val tilt by rememberTiltState() + val tiltX = tilt.x + val tiltY = tilt.y Box( modifier = modifier 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 5a349e6..7365a2b 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -19,24 +19,19 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues 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.ime import androidx.compose.foundation.layout.isImeVisible 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.rememberScrollState -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 @@ -53,7 +48,6 @@ 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.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -61,21 +55,25 @@ 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.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.kaizen.app.net.Attachment +import dev.kaizen.app.ui.effect.GlassSurface +import dev.kaizen.app.ui.effect.GlassTiers +import dev.kaizen.app.ui.effect.KaizenShadows +import dev.kaizen.app.ui.motion.Durations +import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.ui.theme.Amber import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.OnAmber +import dev.kaizen.app.R +import androidx.compose.ui.res.stringResource import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Locale -import kotlin.OptIn import android.graphics.BitmapFactory import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Code @@ -98,57 +96,22 @@ import java.util.concurrent.TimeUnit @Composable fun KaizenHeader(modifier: Modifier = Modifier) { val cs = MaterialTheme.colorScheme - val isDark = isSystemInDarkTheme() - - val glassBg = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color(0xFF1E293B).copy(alpha = 0.65f), // Slate 800 with 65% opacity - Color(0xFF0F172A).copy(alpha = 0.75f) // Slate 900 with 75% opacity - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.85f), - Color(0xFFF1F5F9).copy(alpha = 0.75f) // Slate 100 with 75% opacity - ) - ) - } - } - - val glassBorder = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.16f), // Specular light highlight on top - Color.White.copy(alpha = 0.03f) // Subtle dark fade on bottom - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.55f), - Color.Black.copy(alpha = 0.05f) - ) - ) - } - } - - Row( - modifier + GlassSurface( + modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp) - .clip(RoundedCornerShape(24.dp)) - .background(glassBg) - .border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 16.dp, vertical = 10.dp), + shape = KaizenShapes.lg, + shadowStack = KaizenShadows.level1, + cornerRadius = 24.dp, ) { - KaizenOrb(24.dp) - Spacer(Modifier.width(10.dp)) - Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Row( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + KaizenOrb(24.dp) + Spacer(Modifier.width(10.dp)) + Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + } } } @@ -161,11 +124,6 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN)) } - // Dynamic keyboard visibility check (Google Gemini style) - // When the soft keyboard is visible, we completely hide the Suggestion Pills - // to give the user maximum typing space and keep the layout extremely clean. - val isKeyboardOpen = WindowInsets.isImeVisible - Column( modifier = modifier .fillMaxSize() @@ -173,88 +131,29 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(Modifier.height(48.dp)) // Clean native layout, reduced from 88.dp as there is no top bar + Spacer(Modifier.height(48.dp)) 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 = 32.sp, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.hero_headline), 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) - - if (!isKeyboardOpen) { - Spacer(Modifier.height(32.dp)) - SuggestionPills(onPick) - } - - // Dynamically shrink the bottom spacer when the keyboard is open. - // This ensures the remaining elements fit perfectly into the space above the keyboard - // without causing the column to exceed the screen height and trigger an automatic scroll. - // As a result, the greeting remains perfectly stationary and visible while typing! - val bottomSpacerHeight = if (isKeyboardOpen) 72.dp else 142.dp - Spacer(Modifier.height(bottomSpacerHeight)) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun SuggestionPills(onPick: (String) -> Unit) { - val cs = MaterialTheme.colorScheme - val isDark = isSystemInDarkTheme() - - // Highly responsive 3D floating glass capsules (iOS 26 / Apple Liquid Glass style) - val glassBg = if (isDark) cs.surface.copy(alpha = 0.45f) else cs.surface.copy(alpha = 0.88f) - val glassBorder = if (isDark) { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.02f))) - } else { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.65f), Color.Black.copy(alpha = 0.05f))) - } - - FlowRow( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - suggestions.forEach { suggestion -> - Row( - Modifier - .shadow(elevation = 3.dp, shape = CircleShape, clip = false) // Ambient 3D shadow for floating look - .clip(CircleShape) - .background(glassBg) - .border(1.2.dp, glassBorder, 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) - } - } + Spacer(Modifier.height(142.dp)) } } @Composable -fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifier = Modifier) { +fun ModePillsRow(selected: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() val glassBorderInactive = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.02f)) - ) - } else { - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.45f), Color.Black.copy(alpha = 0.04f)) - ) - } + if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.02f))) + else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.45f), Color.Black.copy(alpha = 0.04f))) } - val glassBorderActive = remember(isDark) { - Brush.verticalGradient( - listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f)) - ) + Brush.verticalGradient(listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f))) } Row( @@ -265,37 +164,24 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie horizontalArrangement = Arrangement.spacedBy(8.dp), ) { chatModes.forEach { mode -> - val active = mode.label == selected - val backgroundFill = if (active) { - Amber.copy(alpha = 0.16f) - } else { - if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF) - } + val label = stringResource(mode.labelRes) + val active = mode.labelRes == selected + val backgroundFill = if (active) Amber.copy(alpha = 0.16f) + else if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF) val borderBrush = if (active) glassBorderActive else glassBorderInactive Row( Modifier - .shadow(elevation = 2.dp, shape = CircleShape, clip = false) // Floating tabs - .clip(CircleShape) + .clip(KaizenShapes.pill) .background(backgroundFill) - .border(1.2.dp, borderBrush, CircleShape) - .clickable { onSelect(mode.label) } + .border(1.2.dp, borderBrush, KaizenShapes.pill) + .clickable { onSelect(mode.labelRes) } .padding(horizontal = 13.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - mode.icon, - null, - tint = if (active) Amber else cs.onSurfaceVariant, - modifier = Modifier.size(16.dp), - ) + Icon(mode.icon, null, tint = if (active) Amber 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, - ) + Text(label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium) } } } @@ -305,43 +191,27 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie fun MessageRow(message: Message) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() - val hasAttachments = message.attachments.isNotEmpty() if (message.role == Role.User) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp) - - val borderBrush = remember { - Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f))) - } - val bgBrush = remember { - Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f))) - } + val shape = KaizenShapes.lg + val borderBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f))) } + val bgBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f))) } Box( Modifier .widthIn(max = 300.dp) - .shadow(elevation = 3.dp, shape = shape, clip = false) .clip(shape) .background(bgBrush) .border(1.2.dp, borderBrush, shape), ) { Column { - if (hasAttachments) { - AttachmentGrid(message.attachments, Modifier.padding(6.dp)) - } + if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp)) if (message.content.isNotBlank()) { Text( - message.content, - color = cs.onBackground, - fontSize = 16.sp, - lineHeight = 22.sp, - modifier = Modifier.padding( - start = 16.dp, end = 16.dp, - top = if (hasAttachments) 4.dp else 11.dp, - bottom = 11.dp, - ), + message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 11.dp, bottom = 11.dp), ) } } @@ -353,14 +223,23 @@ fun MessageRow(message: Message) { Spacer(Modifier.width(10.dp)) Box(Modifier.weight(1f).padding(top = 3.dp)) { Column { + if (message.tools.isNotEmpty()) { + ToolEventsBlock(message.tools, streaming = message.streaming) + Spacer(Modifier.height(8.dp)) + } if (hasAttachments) { AttachmentGrid(message.attachments) if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp)) } - if (message.thinking) { - TypingDots() - } else { - MarkdownText(text = message.content, streaming = message.streaming) + if (message.thinking) TypingDots() + else MarkdownText(text = message.content, streaming = message.streaming) + if (message.reasoning.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + ReasoningBlock(message.reasoning, streaming = message.streaming) + } + if (message.sources.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + SourcesBlock(message.sources, query = message.query) } } } @@ -375,26 +254,18 @@ private fun TypingDots() { Row(verticalAlignment = Alignment.CenterVertically) { repeat(3) { i -> val alpha by transition.animateFloat( - initialValue = 0.25f, - targetValue = 1f, + initialValue = 0.25f, targetValue = 1f, animationSpec = infiniteRepeatable( - tween(600, delayMillis = i * 160, easing = FastOutSlowInEasing), + tween(Durations.THINKING_DOT_CYCLE, delayMillis = i * Durations.THINKING_DOT_PHASE_OFFSET, easing = FastOutSlowInEasing), RepeatMode.Reverse, ), label = "dot$i", ) - Box( - Modifier - .padding(end = 5.dp) - .size(7.dp) - .clip(CircleShape) - .background(cs.onSurfaceVariant.copy(alpha = alpha)), - ) + Box(Modifier.padding(end = 5.dp).size(7.dp).clip(KaizenShapes.circle).background(cs.onSurfaceVariant.copy(alpha = alpha))) } } } -/** A file pending upload or already uploaded, shown as a preview chip above the input. */ data class PendingFile( val id: String = java.util.UUID.randomUUID().toString(), val name: String, @@ -419,98 +290,41 @@ fun ChatInput( val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() - val glassBg = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color(0xFF1E293B).copy(alpha = 0.72f), // Slate 800 translucent - Color(0xFF0F172A).copy(alpha = 0.85f) // Slate 900 translucent - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.90f), - Color(0xFFEEF2F6).copy(alpha = 0.80f) - ) - ) - } - } - - val glassBorder = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.18f), - Color.White.copy(alpha = 0.04f) - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.60f), - Color.Black.copy(alpha = 0.06f) - ) - ) - } - } - - Column( - modifier + GlassSurface( + modifier = modifier .fillMaxWidth() - .padding(horizontal = 12.dp) - .shadow(elevation = 8.dp, shape = RoundedCornerShape(28.dp), clip = false) - .clip(RoundedCornerShape(28.dp)) - .background(glassBg) - .border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)), + .padding(horizontal = 12.dp), + shape = KaizenShapes.xl, + tintAlpha = GlassTiers.input(isDark), + shadowStack = KaizenShadows.level2, + cornerRadius = 28.dp, ) { - if (pendingFiles.isNotEmpty()) { - Row( - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(start = 12.dp, end = 12.dp, top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - pendingFiles.forEach { pf -> - PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) + Column { + if (pendingFiles.isNotEmpty()) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) } } } - } - Row( - Modifier.padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onAddClick), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Rounded.Add, "Anhang", tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) - } - Box( - Modifier - .weight(1f) - .heightIn(min = 36.dp) - .padding(horizontal = 4.dp), - contentAlignment = Alignment.CenterStart - ) { - if (value.isEmpty()) { - Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp) + Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) { + Icon(Icons.Rounded.Add, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) } - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), - cursorBrush = SolidColor(Amber), - maxLines = 6, - modifier = Modifier.fillMaxWidth(), - ) + Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), 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.Call, "Anruf") + Spacer(Modifier.width(4.dp)) + val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading } + SendButton(enabled = canSend, onClick = onSend) } - GhostIconButton(Icons.Rounded.Call, "Anruf") - Spacer(Modifier.width(4.dp)) - val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) - && pendingFiles.none { it.uploading } - SendButton(enabled = canSend, onClick = onSend) } } } @@ -518,7 +332,7 @@ fun ChatInput( @Composable private fun GhostIconButton(icon: ImageVector, contentDescription: String) { val cs = MaterialTheme.colorScheme - Box(Modifier.size(40.dp).clip(CircleShape), contentAlignment = Alignment.Center) { + Box(Modifier.size(40.dp).clip(KaizenShapes.circle), contentAlignment = Alignment.Center) { Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) } } @@ -530,19 +344,9 @@ private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) { val bg = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) val isImage = pf.mimeType.startsWith("image/") - Box( - Modifier - .size(64.dp) - .clip(RoundedCornerShape(10.dp)) - .background(bg), - contentAlignment = Alignment.Center, - ) { + Box(Modifier.size(64.dp).clip(KaizenShapes.xs).background(bg), contentAlignment = Alignment.Center) { if (isImage && pf.uploaded != null) { - NetworkImage( - url = pf.uploaded.url, - contentDescription = pf.name, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(10.dp)), - ) + NetworkImage(url = pf.uploaded.url, contentDescription = pf.name, modifier = Modifier.fillMaxSize().clip(KaizenShapes.xs)) } else { val icon = when { pf.mimeType.startsWith("image/") -> Icons.Rounded.Image @@ -553,46 +357,21 @@ private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) { } Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp)) - Text( - pf.name, - color = cs.onSurfaceVariant, - fontSize = 9.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 4.dp), - ) + Text(pf.name, color = cs.onSurfaceVariant, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 4.dp)) } } if (pf.uploading) { - Box( - Modifier.fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)), - contentAlignment = Alignment.Center, - ) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = Color.White, - ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) { + androidx.compose.material3.CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White) } } if (pf.error != null) { - Box( - Modifier.fillMaxSize() - .background(Color(0xFFDC2626).copy(alpha = 0.3f)), - contentAlignment = Alignment.Center, - ) { + Box(Modifier.fillMaxSize().background(Color(0xFFDC2626).copy(alpha = 0.3f)), contentAlignment = Alignment.Center) { Icon(Icons.Rounded.Close, "Fehler", tint = Color.White, modifier = Modifier.size(18.dp)) } } Box( - Modifier - .align(Alignment.TopEnd) - .padding(2.dp) - .size(18.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(onClick = onRemove), + Modifier.align(Alignment.TopEnd).padding(2.dp).size(18.dp).clip(KaizenShapes.circle).background(Color.Black.copy(alpha = 0.5f)).clickable(onClick = onRemove), contentAlignment = Alignment.Center, ) { Icon(Icons.Rounded.Close, "Entfernen", tint = Color.White, modifier = Modifier.size(12.dp)) @@ -606,18 +385,12 @@ 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 fill = if (enabled) { - Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep))) - } else { - Modifier.background(cs.surface) - } + val fill = if (enabled) Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep))) + else Modifier.background(cs.surface) + Box( - Modifier - .size(44.dp) - .scale(pressScale) - .clip(CircleShape) - .then(fill) - .border(1.dp, cs.outline, CircleShape) + Modifier.size(44.dp).scale(pressScale).clip(KaizenShapes.circle).then(fill) + .border(1.dp, cs.outline, KaizenShapes.circle) .clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -631,10 +404,7 @@ private object NetworkImageCache { private val cache = object : LinkedHashMap(32, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 50 } - private val client = OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() + private val client = OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build() suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) { synchronized(cache) { cache[url] }?.let { return@withContext it } @@ -647,8 +417,7 @@ private object NetworkImageCache { BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe) val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536) val opts = BitmapFactory.Options().apply { inSampleSize = scale } - val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) - ?: return@withContext null + val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } } } } catch (_: Exception) { null } @@ -656,31 +425,14 @@ private object NetworkImageCache { } @Composable -private fun NetworkImage( - url: String, - contentDescription: String?, - modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Crop, -) { +private fun NetworkImage(url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop) { var bitmap by remember(url) { mutableStateOf(null) } LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) } - val bmp = bitmap if (bmp != null) { - Image( - bitmap = bmp, - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale, - ) + Image(bitmap = bmp, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale) } else { - Box( - modifier - .fillMaxWidth() - .height(160.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), - ) + Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))) } } @@ -689,17 +441,9 @@ fun AttachmentGrid(attachments: List, modifier: Modifier = Modifier) if (attachments.isEmpty()) return val images = attachments.filter { it.kind == "image" } val others = attachments.filter { it.kind != "image" } - Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { images.forEach { att -> - NetworkImage( - url = att.url, - contentDescription = att.name, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 280.dp) - .clip(RoundedCornerShape(10.dp)), - ) + NetworkImage(url = att.url, contentDescription = att.name, modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp).clip(KaizenShapes.xs)) } others.forEach { att -> FileChip(att) } } @@ -717,33 +461,17 @@ private fun FileChip(attachment: Attachment) { "code" -> Icons.Rounded.Code else -> Icons.AutoMirrored.Rounded.InsertDriveFile } - Row( - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(bg) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + Row(Modifier.clip(KaizenShapes.xs).background(bg).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) - Text( - attachment.name, - color = cs.onSurfaceVariant, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text(attachment.name, color = cs.onSurfaceVariant, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } } -/** Crisp upward arrow drawn with strokes (sharper than a glyph). */ @Composable private fun SendArrow(color: Color) { Canvas(Modifier.size(18.dp)) { - val w = size.width - val h = size.height - val cx = w / 2f - val stroke = w * 0.135f + val w = size.width; val h = size.height; val cx = w / 2f; val stroke = w * 0.135f drawLine(color, Offset(cx, h * 0.82f), Offset(cx, h * 0.20f), stroke, StrokeCap.Round) drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.28f, h * 0.46f), stroke, StrokeCap.Round) drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.72f, h * 0.46f), stroke, StrokeCap.Round) 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 8a94a3e..e20b3c2 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -8,8 +8,13 @@ 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.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import dev.kaizen.app.R import dev.kaizen.app.net.Attachment +import dev.kaizen.app.net.SearchSource +import dev.kaizen.app.net.ToolEvent enum class Role { User, Assistant } @@ -21,33 +26,36 @@ data class Message( val thinking: Boolean = false, val wireId: String = java.util.UUID.randomUUID().toString(), val attachments: List = emptyList(), + val reasoning: String = "", + val tools: List = emptyList(), + val query: String = "", + val sources: List = emptyList(), ) -// Empty-state suggestion pills (mirrors the web hero) -data class Suggestion(val label: String, val prompt: String, val icon: ImageVector) +data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector) val suggestions = listOf( - 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), + Suggestion(R.string.suggest_brainstorm, R.string.suggest_brainstorm_prompt, Icons.Rounded.AutoAwesome), + Suggestion(R.string.suggest_image, R.string.suggest_image_prompt, Icons.Rounded.Image), + Suggestion(R.string.suggest_video, R.string.suggest_video_prompt, Icons.Rounded.Movie), + Suggestion(R.string.suggest_web, R.string.suggest_web_prompt, Icons.Rounded.Language), ) -// Mode pills above the input (visual only in Phase 0) -data class ChatMode(val label: String, val icon: ImageVector) +data class ChatMode(val labelRes: Int, 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), + ChatMode(R.string.mode_standard, Icons.Rounded.Adjust), + ChatMode(R.string.mode_sampling, Icons.Rounded.Tune), + ChatMode(R.string.mode_image, Icons.Rounded.Image), + ChatMode(R.string.mode_search, Icons.Rounded.Language), + ChatMode(R.string.mode_video, Icons.Rounded.Movie), + ChatMode(R.string.mode_audio, Icons.Rounded.MusicNote), ) +@Composable fun greeting(hour: Int): String = when (hour) { - in 5..11 -> "Guten Morgen" - in 12..17 -> "Guten Tag" - in 18..22 -> "Guten Abend" - else -> "Hallo" + in 5..11 -> stringResource(R.string.greeting_morning) + in 12..17 -> stringResource(R.string.greeting_day) + in 18..22 -> stringResource(R.string.greeting_evening) + else -> stringResource(R.string.greeting_night) } 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 d8679c4..c5ea12a 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Menu import androidx.compose.material3.DrawerValue @@ -45,7 +43,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration @@ -53,6 +50,11 @@ import androidx.compose.material3.Text import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.kaizen.app.ui.effect.GlassSurface +import dev.kaizen.app.ui.effect.GlassTiers +import dev.kaizen.app.ui.effect.KaizenShadows +import dev.kaizen.app.ui.shape.KaizenShapes +import dev.kaizen.app.R import dev.kaizen.app.db.MessageEntity import dev.kaizen.app.db.toEntity import dev.kaizen.app.haptics.rememberHaptics @@ -92,7 +94,7 @@ fun ChatScreen( var input by remember { mutableStateOf("") } var isStreaming by remember { mutableStateOf(false) } var nextId by remember { mutableStateOf(0L) } - var selectedMode by remember { mutableStateOf("Standard") } + var selectedMode by remember { mutableStateOf(R.string.mode_standard) } // Navigation State var currentScreen by remember { mutableStateOf(AppScreen.Chat) } @@ -156,6 +158,10 @@ fun ChatScreen( } is FetchResult.Fail -> errors.add(r.reason) } + KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me -> + me.name?.let { settingsViewModel.updateUserName(it) } + me.email?.let { settingsViewModel.updateUserEmail(it) } + } chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) if (errors.isNotEmpty()) { loadError = errors.joinToString(" · ") @@ -261,16 +267,20 @@ fun ChatScreen( KaizenApi.chat( cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed, attachments = uploadedAtts.takeIf { it.isNotEmpty() }, - ).collect { visible -> + ).collect { state -> val idx = messages.indexOfLast { it.id == assistantId } if (idx < 0) return@collect - if (!sawContent && visible.isNotEmpty()) { + if (!sawContent && state.content.isNotEmpty()) { sawContent = true haptics.responseStart() } messages[idx] = messages[idx].copy( thinking = if (sawContent) false else messages[idx].thinking, - content = visible, + content = state.content, + reasoning = state.reasoning, + tools = state.tools, + query = state.query, + sources = state.sources, ) } val idx = messages.indexOfLast { it.id == assistantId } @@ -314,11 +324,11 @@ fun ChatScreen( val idx = messages.indexOfLast { it.id == assistantId } if (idx >= 0) { val existing = messages[idx].content - val errorSuffix = "\n\n⚠ ${chatErrorText(e)}" + val errorSuffix = "\n\n⚠ ${chatErrorText(e, context)}" messages[idx] = messages[idx].copy( streaming = false, thinking = false, - content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e), + content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e, context), ) } } finally { @@ -416,19 +426,7 @@ fun ChatScreen( } } - // 2. Floating Top Menu Button (Apple Liquid Glass - completely independent of a header bar) - val isDark = remember { isStreaming || messages.isNotEmpty() } // slight adaptive context tint - val menuGlassBg = if (isDark.not() && isSystemInDarkTheme().not()) { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFF1F5F9).copy(alpha = 0.75f))) - } else { - Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.65f), Color(0xFF0F172A).copy(alpha = 0.75f))) - } - val menuGlassBorder = if (isDark.not() && isSystemInDarkTheme().not()) { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.Black.copy(alpha = 0.05f))) - } else { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f))) - } - + // 2. Floating Top Menu Button Row( modifier = Modifier .align(Alignment.TopStart) @@ -436,22 +434,23 @@ fun ChatScreen( .padding(start = 16.dp, top = 10.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box( + GlassSurface( + shape = KaizenShapes.circle, + tintAlpha = GlassTiers.pill(isSystemInDarkTheme()), + shadowStack = KaizenShadows.level2, + cornerRadius = 22.dp, modifier = Modifier - .shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds - .clip(CircleShape) - .background(menuGlassBg) - .border(1.2.dp, menuGlassBorder, CircleShape) - .clickable { haptics.tick(); scope.launch { drawerState.open() } } - .size(44.dp), - contentAlignment = Alignment.Center + .size(44.dp) + .clickable { haptics.tick(); scope.launch { drawerState.open() } }, ) { - Icon( - imageVector = Icons.Rounded.Menu, - contentDescription = "Menü öffnen", - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(20.dp) - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Menu, + contentDescription = context.getString(R.string.chat_open_menu), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(20.dp) + ) + } } Spacer(Modifier.width(8.dp)) ModelPill( @@ -468,9 +467,9 @@ fun ChatScreen( .align(Alignment.TopCenter) .statusBarsPadding() .padding(top = 62.dp, start = 24.dp, end = 24.dp) - .clip(RoundedCornerShape(12.dp)) + .clip(KaizenShapes.sm) .background(Color(0xFFDC2626).copy(alpha = 0.15f)) - .border(1.dp, Color(0xFFDC2626).copy(alpha = 0.3f), RoundedCornerShape(12.dp)) + .border(1.dp, Color(0xFFDC2626).copy(alpha = 0.3f), KaizenShapes.sm) .padding(horizontal = 14.dp, vertical = 8.dp), ) { Text( @@ -521,11 +520,10 @@ fun ChatScreen( } } -/** Maps a streaming failure to a short, human German message shown in the bubble. */ -private fun chatErrorText(e: Throwable): String = when { - e is ChatHttpException && e.code == 401 -> "Sitzung abgelaufen. Bitte erneut anmelden." - e is ChatHttpException && e.code == 402 -> "Guthaben aufgebraucht." - e is ChatHttpException && e.code == 429 -> "Zu viele Anfragen. Kurz warten." - e is ChatHttpException -> "Fehler vom Server (${e.code})." - else -> "Keine Verbindung zum Server." +private fun chatErrorText(e: Throwable, ctx: android.content.Context): String = when { + e is ChatHttpException && e.code == 401 -> ctx.getString(R.string.error_session_expired) + e is ChatHttpException && e.code == 402 -> ctx.getString(R.string.error_credits_exhausted) + e is ChatHttpException && e.code == 429 -> ctx.getString(R.string.error_rate_limited) + e is ChatHttpException -> ctx.getString(R.string.error_server, e.code) + else -> ctx.getString(R.string.error_no_connection) } diff --git a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt index 0988e20..16d4ed5 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt @@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import dev.kaizen.app.ui.shape.KaizenShapes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.ContentCopy @@ -509,7 +509,7 @@ private fun CopyButton(code: String) { Box( Modifier .size(28.dp) - .clip(CircleShape) + .clip(KaizenShapes.circle) .clickable { haptics.tick() copied = true diff --git a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt index 4e7127e..ba69255 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt @@ -6,8 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import dev.kaizen.app.ui.shape.KaizenShapes import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.* @@ -35,9 +34,9 @@ fun ModelPill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) val cs = MaterialTheme.colorScheme Row( modifier = modifier - .clip(RoundedCornerShape(22.dp)) + .clip(KaizenShapes.xl) .background(cs.surface.copy(alpha = 0.7f)) - .border(1.dp, cs.onSurface.copy(alpha = 0.08f), RoundedCornerShape(22.dp)) + .border(1.dp, cs.onSurface.copy(alpha = 0.08f), KaizenShapes.xl) .clickable { onClick() } .padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp), verticalAlignment = Alignment.CenterVertically, @@ -128,9 +127,9 @@ fun ModelSheet( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) + .clip(KaizenShapes.md) .background(cs.surfaceVariant.copy(alpha = 0.4f)) - .border(1.dp, cs.onSurface.copy(alpha = 0.06f), RoundedCornerShape(16.dp)) + .border(1.dp, cs.onSurface.copy(alpha = 0.06f), KaizenShapes.md) .padding(horizontal = 14.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -229,7 +228,7 @@ private fun ModelRow( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(KaizenShapes.md) .background( if (selected) { Amber.copy(alpha = 0.08f) @@ -240,7 +239,7 @@ private fun ModelRow( .border( width = 1.dp, color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent, - shape = RoundedCornerShape(16.dp) + shape = KaizenShapes.md ) .clickable { haptics.tick(); onClick() } .padding(horizontal = 14.dp, vertical = 12.dp), @@ -249,7 +248,7 @@ private fun ModelRow( // Dynamic Colored Badge for Provider (Vertex AI or OpenRouter) Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) + .clip(KaizenShapes.xs) .background(providerInfo.color.copy(alpha = 0.15f)) .padding(horizontal = 8.dp, vertical = 4.dp) ) { @@ -284,7 +283,7 @@ private fun ModelRow( Box( Modifier .size(36.dp) - .clip(CircleShape) + .clip(KaizenShapes.circle) .clickable { haptics.tick(); onToggleFavorite() }, contentAlignment = Alignment.Center, ) { diff --git a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt index 16d0763..4b9dd5d 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt @@ -3,7 +3,6 @@ package dev.kaizen.app.chat import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,12 +17,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.Edit @@ -39,49 +35,47 @@ 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.shadow -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.kaizen.app.net.ConversationSummary +import dev.kaizen.app.ui.effect.GlassSurface +import dev.kaizen.app.ui.effect.GlassTiers +import dev.kaizen.app.ui.effect.KaizenShadows +import dev.kaizen.app.ui.shape.KaizenShapes +import dev.kaizen.app.R import dev.kaizen.app.ui.theme.Amber +import androidx.compose.ui.res.stringResource import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale -/** Date-bucket header for a conversation, from its ISO `updatedAt` (Heute/Gestern/dd. MMM yyyy). */ -private fun dateLabel(updatedAt: String?): String { - val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER" +private data class DateLabels(val today: String, val yesterday: String, val earlier: String, val pinned: String) + +private fun dateLabel(updatedAt: String?, labels: DateLabels): String { + val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return labels.earlier val date = instant.atZone(ZoneId.systemDefault()).toLocalDate() val today = LocalDate.now() return when (date) { - today -> "HEUTE" - today.minusDays(1) -> "GESTERN" - else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN)).uppercase() + today -> labels.today + today.minusDays(1) -> labels.yesterday + else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.getDefault())).uppercase() } } -/** Pinned first, then chronological date buckets — preserves the API's desc-updatedAt order. */ -private fun groupConversations(conversations: List): Map> { +private fun groupConversations(conversations: List, labels: DateLabels): Map> { val groups = LinkedHashMap>() - conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() } + conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups[labels.pinned] = it.toMutableList() } conversations.filterNot { it.pinned }.forEach { - groups.getOrPut(dateLabel(it.updatedAt)) { mutableListOf() }.add(it) + groups.getOrPut(dateLabel(it.updatedAt, labels)) { mutableListOf() }.add(it) } return groups } -/** - * Floating glassmorphic sidebar drawer content. - * - * Styled as a suspended glass panel with a thick "milky glass" background, - * dual-border specular light reflections, deep ambient shadows, and authentic Kaizen layouts. - */ @Composable fun KaizenSidebar( userName: String, @@ -97,150 +91,91 @@ fun KaizenSidebar( val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() - // 1. Thick, premium milky-glass background gradient - val glassBg = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color(0xCC141B27), // Deep Obsidian-blue with 80% opacity - Color(0xE60A0E16) // Even darker at the bottom (90% opacity) - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.94f), // Creamy white with 94% opacity - Color(0xFFF1F5F9).copy(alpha = 0.85f) // Slightly slate tint at bottom - ) - ) - } - } - - // 2. Specular border light reflections (crisp glass edge highlight) - val glassBorder = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.16f), // Bright highlight on top-left - Color.White.copy(alpha = 0.02f) // Faint dark shade on bottom - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.65f), - Color.Black.copy(alpha = 0.05f) - ) - ) - } - } - - // Floating design: 12dp margins all around so it physically hovers above the background! - Box( + GlassSurface( modifier = modifier .fillMaxHeight() .width(300.dp) - .padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp) // Standard signature (start, top, end, bottom) - .shadow(elevation = 16.dp, shape = RoundedCornerShape(24.dp), clip = false) - .clip(RoundedCornerShape(24.dp)) - .background(glassBg) - .border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) - .statusBarsPadding() - .padding(16.dp) + .padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp), + shape = KaizenShapes.lg, + tintAlpha = GlassTiers.sidebar(isDark), + shadowStack = KaizenShadows.level3, + cornerRadius = 24.dp, ) { - Column(Modifier.fillMaxSize()) { - - // --- HEADER Sektion: Brand title and New Chat button --- + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp) + ) { + // Header Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "kaizen", - color = cs.onBackground, - fontSize = 20.sp, - fontWeight = FontWeight.Bold - ) - - // Translucent "New Chat" pencil button + Text(stringResource(R.string.sidebar_brand), color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold) Box( modifier = Modifier .size(36.dp) - .clip(CircleShape) + .clip(KaizenShapes.circle) .background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000)) .clickable { onNewChat() }, contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = "Neuer Chat", - tint = cs.onBackground, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(18.dp)) } } Spacer(Modifier.height(16.dp)) - // --- SEARCH Sektion: Glassmorphic search capsule --- + // Search Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(KaizenShapes.sm) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) - .border( - 1.dp, - if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), - RoundedCornerShape(12.dp) - ) + .border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Rounded.Search, - contentDescription = null, - tint = cs.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text( - text = "Suchen...", - color = cs.onSurfaceVariant.copy(alpha = 0.6f), - fontSize = 14.sp - ) + Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp) } Spacer(Modifier.height(24.dp)) - // --- BODY Sektion: Scrollable Chats list grouped by Date --- + // Conversation list Box(Modifier.weight(1f).fillMaxWidth()) { if (conversations.isEmpty()) { Text( - text = "Noch keine Chats", + stringResource(R.string.chat_no_conversations), color = cs.onSurfaceVariant.copy(alpha = 0.5f), fontSize = 14.sp, modifier = Modifier.padding(start = 12.dp, top = 12.dp) ) } else { - val grouped = remember(conversations) { groupConversations(conversations) } - + val labels = DateLabels( + today = stringResource(R.string.sidebar_today), + yesterday = stringResource(R.string.sidebar_yesterday), + earlier = stringResource(R.string.sidebar_earlier), + pinned = stringResource(R.string.sidebar_pinned), + ) + val grouped = remember(conversations, labels) { groupConversations(conversations, labels) } LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { grouped.forEach { (dateGroup, items) -> - // Date header (e.g., HEUTE, 16. JUN 2026) item(key = "header_$dateGroup") { Text( - text = dateGroup, + dateGroup, color = cs.onSurfaceVariant.copy(alpha = 0.45f), fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) ) } - items(items, key = { it.id }) { item -> HistoryItemRow( item = item, @@ -255,81 +190,50 @@ fun KaizenSidebar( Spacer(Modifier.height(16.dp)) - // --- FOOTER Sektion: User Profile Card --- - // Clicking the card opens the brand settings screen cleanly (MVVM state) + // User card Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) + .clip(KaizenShapes.md) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) - .border( - 1.dp, - if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), - RoundedCornerShape(18.dp) - ) + .border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md) .clickable { onOpenSettings() } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - // User Avatar bubble: Obsidian colored dynamic first-letter initials! Box( modifier = Modifier .size(40.dp) - .clip(CircleShape) + .clip(KaizenShapes.circle) .background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)), contentAlignment = Alignment.Center ) { Text( - text = userName.firstOrNull()?.toString()?.uppercase() ?: "A", - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.Bold + userName.firstOrNull()?.toString()?.uppercase() ?: "A", + color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold ) } - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - text = userName, - color = cs.onBackground, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold - ) + Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) } - - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = "Einstellungen", - tint = cs.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.size(20.dp) - ) + Icon(Icons.Rounded.Settings, stringResource(R.string.sidebar_settings), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp)) } Spacer(Modifier.height(10.dp)) - // --- Logout: clears the encrypted token + returns to the login screen --- + // Logout Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) + .clip(KaizenShapes.sm) .clickable { onLogout() } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Logout, - contentDescription = "Abmelden", - tint = cs.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.size(18.dp) - ) + Icon(Icons.AutoMirrored.Rounded.Logout, stringResource(R.string.sidebar_logout), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp)) Spacer(Modifier.width(10.dp)) - Text( - text = "Abmelden", - color = cs.onSurfaceVariant.copy(alpha = 0.85f), - fontSize = 14.sp, - fontWeight = FontWeight.Medium - ) + Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium) } } } @@ -354,40 +258,28 @@ private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick Row( modifier = Modifier .fillMaxWidth() - .clip(CircleShape) + .clip(KaizenShapes.pill) .background(itemBg) - .border(1.2.dp, itemBorder, CircleShape) + .border(1.2.dp, itemBorder, KaizenShapes.pill) .clickable { onClick() } .padding(horizontal = 16.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically ) { if (item.pinned) { - Icon( - imageVector = Icons.Rounded.Star, - contentDescription = null, - tint = Amber, - modifier = Modifier.size(14.dp) - ) + Icon(Icons.Rounded.Star, null, tint = Amber, modifier = Modifier.size(14.dp)) Spacer(Modifier.width(8.dp)) } - Text( - text = if (item.locked) "Gesperrter Chat" else item.title, + text = if (item.locked) stringResource(R.string.chat_locked) else item.title, color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground, fontSize = 14.sp, fontWeight = FontWeight.Medium, fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal, modifier = Modifier.weight(1f) ) - if (item.locked) { Spacer(Modifier.width(8.dp)) - Icon( - imageVector = Icons.Rounded.Lock, - contentDescription = "Gesperrt", - tint = Amber, - modifier = Modifier.size(15.dp) - ) + Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = Amber, modifier = Modifier.size(15.dp)) } } } diff --git a/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt b/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt new file mode 100644 index 0000000..0764fc5 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt @@ -0,0 +1,215 @@ +package dev.kaizen.app.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Psychology +import androidx.compose.material.icons.rounded.Build +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.kaizen.app.net.SearchSource +import dev.kaizen.app.net.ToolEvent +import dev.kaizen.app.ui.shape.KaizenShapes +import dev.kaizen.app.ui.theme.Amber + +@Composable +fun ReasoningBlock(reasoning: String, streaming: Boolean = false) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + var expanded by remember { mutableStateOf(streaming) } + + val bg = if (isDark) Color.White.copy(alpha = 0.05f) else Color.Black.copy(alpha = 0.03f) + val borderColor = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) + + Column( + Modifier + .fillMaxWidth() + .clip(KaizenShapes.sm) + .background(bg) + .border(1.dp, borderColor, KaizenShapes.sm) + ) { + Row( + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Rounded.Psychology, null, tint = Amber, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text( + "Gedankengang", + color = cs.onSurfaceVariant, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Icon( + if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp), + ) + } + AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) { + Text( + reasoning, + color = cs.onSurfaceVariant.copy(alpha = 0.8f), + fontSize = 13.sp, + lineHeight = 18.sp, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp), + ) + } + } +} + +@Composable +fun ToolEventsBlock(tools: List, streaming: Boolean = false) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + val bg = if (isDark) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.02f) + val borderColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f) + + Column( + Modifier + .fillMaxWidth() + .clip(KaizenShapes.sm) + .background(bg) + .border(1.dp, borderColor, KaizenShapes.sm) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + tools.forEach { tool -> + Row(verticalAlignment = Alignment.CenterVertically) { + val icon = when (tool.type) { + "end" -> Icons.Rounded.Check + "error" -> Icons.Rounded.Error + else -> Icons.Rounded.Build + } + val tint = when (tool.type) { + "end" -> Color(0xFF10B981) + "error" -> Color(0xFFEF4444) + else -> Amber + } + Icon(icon, null, tint = tint, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(8.dp)) + Text( + tool.name.ifEmpty { "Tool" }, + color = cs.onBackground, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + if (tool.elapsed != null) { + Spacer(Modifier.width(6.dp)) + Text( + "${tool.elapsed}ms", + color = cs.onSurfaceVariant.copy(alpha = 0.5f), + fontSize = 11.sp, + ) + } + if (tool.error != null) { + Spacer(Modifier.width(6.dp)) + Text(tool.error, color = Color(0xFFEF4444), fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SourcesBlock(sources: List, query: String = "") { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + + Column { + if (query.isNotBlank()) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 6.dp)) { + Icon(Icons.Rounded.Language, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(6.dp)) + Text(query, color = cs.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + sources.forEach { source -> + SourceChip(source, isDark) + } + } + } +} + +@Composable +private fun SourceChip(source: SearchSource, isDark: Boolean) { + val cs = MaterialTheme.colorScheme + val bg = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f) + val borderColor = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) + + val domain = remember(source.url) { + try { + java.net.URI(source.url).host?.removePrefix("www.") ?: source.url + } catch (_: Exception) { source.url } + } + + Column( + Modifier + .width(140.dp) + .clip(KaizenShapes.xs) + .background(bg) + .border(1.dp, borderColor, KaizenShapes.xs) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text( + source.title.ifEmpty { domain }, + color = cs.onBackground, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + domain, + color = cs.onSurfaceVariant.copy(alpha = 0.6f), + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 8fb1cf3..4a45e7b 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -23,7 +23,11 @@ import java.util.concurrent.TimeUnit @Serializable private data class TokenRequest(val email: String, val password: String, val name: String) @Serializable private data class TokenResponse(val token: String) -@Serializable private data class MeResponse(val defaultModel: String? = null) +@Serializable data class MeResponse( + val name: String? = null, + val email: String? = null, + val defaultModel: String? = null, +) @Serializable data class WireMessage(val role: String, val content: String) @Serializable private data class ChatRequest( val model: String, @@ -164,6 +168,22 @@ object KaizenApi { } /** GET /api/v1/me — resolve the user's configured default model (best-effort, null on any failure). */ + suspend fun fetchMe(baseUrl: String, token: String): MeResponse? = + withContext(Dispatchers.IO) { + val req = Request.Builder() + .url("$baseUrl/api/v1/me") + .header("Authorization", "Bearer $token") + .build() + try { + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@use null + json.decodeFromString(resp.body!!.string()) + } + } catch (e: Exception) { + null + } + } + suspend fun fetchDefaultModel(baseUrl: String, token: String): String? = withContext(Dispatchers.IO) { val req = Request.Builder() @@ -330,7 +350,7 @@ object KaizenApi { messages: List, speed: ChatSpeed = ChatSpeed.Normal, attachments: List? = null, - ): Flow = flow { + ): Flow = flow { val payload = json.encodeToString( ChatRequest( model = model, diff --git a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt index 66aa942..9d44aa0 100644 --- a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt +++ b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt @@ -77,6 +77,20 @@ class SecureStore(context: Context) { } } + // ── Settings persistence ───────────────────────────────────────────────── + + fun loadThemeId(): String? = prefs.getString(KEY_THEME_ID, null) + fun saveThemeId(id: String) { prefs.edit().putString(KEY_THEME_ID, id).apply() } + + fun loadAppearance(): String? = prefs.getString(KEY_APPEARANCE, null) + fun saveAppearance(mode: String) { prefs.edit().putString(KEY_APPEARANCE, mode).apply() } + + fun loadUserName(): String? = prefs.getString(KEY_USER_NAME, null) + fun saveUserName(name: String) { prefs.edit().putString(KEY_USER_NAME, name).apply() } + + fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null) + fun saveLocale(locale: String) { prefs.edit().putString(KEY_LOCALE, locale).apply() } + fun clear() { prefs.edit().clear().apply() } @@ -89,5 +103,9 @@ class SecureStore(context: Context) { const val KEY_SPEED = "speed" const val KEY_FAVORITES = "favorite_models" const val KEY_MODELS_CACHE = "models_cache" + const val KEY_THEME_ID = "ui_theme" + const val KEY_APPEARANCE = "appearance" + const val KEY_USER_NAME = "user_name" + const val KEY_LOCALE = "locale" } } diff --git a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt index 91324b4..8158851 100644 --- a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt +++ b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt @@ -1,54 +1,54 @@ package dev.kaizen.app.net -/** - * Sentinel code points delimiting control sections in the `/api/v1/chat` stream. - * Shared between the batch parser ([StreamConsumer.visibleText]) and the - * incremental parser ([StreamConsumer.Incremental]). - */ -private val USAGE = 0.toChar() // -private val REASONING = 1.toChar() // -private val SOURCES = 2.toChar() // -private val TOOL = 3.toChar() // -private val QUERY = 4.toChar() // +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private val USAGE = 0.toChar() +private val REASONING = 1.toChar() +private val SOURCES = 2.toChar() +private val TOOL = 3.toChar() +private val QUERY = 4.toChar() + +private val lenientJson = Json { ignoreUnknownKeys = true } + +@Serializable +data class ToolEvent( + val type: String = "", + val name: String = "", + val args: kotlinx.serialization.json.JsonObject? = null, + val result: String? = null, + val error: String? = null, + val elapsed: Long? = null, +) + +@Serializable +data class SearchSource( + val title: String = "", + val url: String = "", + val snippet: String = "", +) + +data class StreamState( + val content: String = "", + val reasoning: String = "", + val tools: List = emptyList(), + val query: String = "", + val sources: List = emptyList(), +) -/** - * Pure parsing layer for the `/api/v1/chat` response stream — a Kotlin port of the - * backend's `lib/chat/stream-consumer.ts`. The provider stream is a flat string - * with control sentinels (code points, see lib/chat/constants.ts): - * - * 0x00 USAGE trailing usage/cost json - * 0x01 REASONING wraps reasoning, toggling content <-> reasoning - * 0x02 SOURCES web-search sources json - * 0x03 TOOL leading newline-framed tool events ("{json}\n") - * 0x04 QUERY web-search query json - * - * Layout: [tool-events] [0x01 reasoning 0x01 content] [0x04 q] [0x02 src] [0x00 usage] - * - * Two parsers are available: - * - [visibleText]: batch — correct but O(N) per call, used for one-shot parsing and tests. - * - [Incremental]: streaming — O(chunk-size) per call, O(N) total. Used during live streaming. - */ object StreamConsumer { - /** - * Batch extraction — parses the full accumulated buffer from scratch. - * Correct but O(N) per call; kept for unit tests and one-shot use. - */ fun visibleText(raw: String): String { val rest = stripLeadingToolEvents(raw) ?: return "" - val segments = rest.split(REASONING) val sb = StringBuilder() for (i in segments.indices) { if (i % 2 == 0) sb.append(segments[i]) } var text = sb.toString() - val cut = listOf(text.indexOf(QUERY), text.indexOf(SOURCES), text.indexOf(USAGE)) - .filter { it != -1 } - .minOrNull() + .filter { it != -1 }.minOrNull() if (cut != null) text = text.substring(0, cut) - return text } @@ -56,38 +56,33 @@ object StreamConsumer { var rest = raw while (rest.isNotEmpty() && rest[0] == TOOL) { val nl = rest.indexOf('\n') - if (nl == -1) return null // incomplete event — wait for more data + if (nl == -1) return null rest = rest.substring(nl + 1) } return rest } - /** - * Incremental parser — O(chunk-size) per [append] instead of O(total-buffer). - * Each character is visited exactly once across all calls, giving O(N) total - * for the entire stream instead of O(N²). - * - * Operates on decoded [Char]s — the caller must use [java.io.InputStreamReader] - * to handle UTF-8 multi-byte boundaries at the byte level. - */ class Incremental { private val content = StringBuilder(256) + private val reasoningBuf = StringBuilder(256) private val toolBuffer = StringBuilder() + private val trailingBuf = StringBuilder() + private val tools = mutableListOf() private var pastToolEvents = false private var inReasoning = false + private var inTrailing = false private var done = false + private var trailingSentinel: Char? = null - /** - * Feed a chunk of decoded characters. Returns the current visible text. - * Safe to call on every network chunk — only the new characters are scanned. - */ - fun append(chars: CharArray, offset: Int, length: Int): String { - if (done) return content.toString() + fun append(chars: CharArray, offset: Int, length: Int): StreamState { + if (done) return buildState() if (!pastToolEvents) { toolBuffer.append(chars, offset, length) + if (toolBuffer.isEmpty()) return buildState() val end = findToolEventsEnd(toolBuffer) - if (end == -1) return "" + if (end == -1) return buildState() + parseToolEvents(toolBuffer, 0, end) pastToolEvents = true processRange(toolBuffer, end, toolBuffer.length) toolBuffer.setLength(0) @@ -96,11 +91,63 @@ object StreamConsumer { processChars(chars, offset, length) } - return content.toString() + return buildState() } - /** Convenience overload for string input (tests). */ - fun append(chunk: String): String = append(chunk.toCharArray(), 0, chunk.length) + fun append(chunk: String): StreamState = append(chunk.toCharArray(), 0, chunk.length) + + fun currentContent(): String = content.toString() + + private fun buildState(): StreamState { + val queryStr = parseTrailingField(QUERY) + val sourcesList = parseTrailingSources() + return StreamState( + content = content.toString(), + reasoning = reasoningBuf.toString(), + tools = tools.toList(), + query = queryStr, + sources = sourcesList, + ) + } + + private fun parseTrailingField(sentinel: Char): String { + val full = trailingBuf.toString() + val idx = full.indexOf(sentinel) + if (idx == -1) return "" + val after = full.substring(idx + 1) + val end = listOf(after.indexOf(SOURCES), after.indexOf(USAGE), after.indexOf(QUERY)) + .filter { it != -1 }.minOrNull() ?: after.length + return after.substring(0, end).trim() + } + + private fun parseTrailingSources(): List { + val full = trailingBuf.toString() + val idx = full.indexOf(SOURCES) + if (idx == -1) return emptyList() + val after = full.substring(idx + 1) + val end = listOf(after.indexOf(USAGE)).filter { it != -1 }.minOrNull() ?: after.length + val json = after.substring(0, end).trim() + if (json.isEmpty()) return emptyList() + return try { + lenientJson.decodeFromString>(json) + } catch (_: Exception) { + emptyList() + } + } + + private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) { + val s = buf.toString() + var pos = from + while (pos < to && s[pos] == TOOL) { + val nl = s.indexOf('\n', pos) + if (nl == -1 || nl >= to) break + val jsonStr = s.substring(pos + 1, nl) + try { + tools.add(lenientJson.decodeFromString(jsonStr)) + } catch (_: Exception) { } + pos = nl + 1 + } + } private fun processRange(buf: CharSequence, from: Int, to: Int) { for (i in from until to) { @@ -118,18 +165,24 @@ object StreamConsumer { } private fun processChar(c: Char) { - when (c) { - REASONING -> inReasoning = !inReasoning - USAGE, SOURCES, QUERY -> done = true - else -> if (!inReasoning) content.append(c) + when { + inTrailing -> trailingBuf.append(c) + c == REASONING -> inReasoning = !inReasoning + c == QUERY || c == SOURCES || c == USAGE -> { + inTrailing = true + trailingBuf.append(c) + } + inReasoning -> reasoningBuf.append(c) + else -> content.append(c) } } companion object { private fun findToolEventsEnd(buf: StringBuilder): Int { var pos = 0 - while (pos < buf.length && buf[pos] == TOOL) { - val nl = buf.indexOf("\n", pos) + val s = buf.toString() + while (pos < s.length && s[pos] == TOOL) { + val nl = s.indexOf('\n', pos) if (nl == -1) return -1 pos = nl + 1 } diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt index 59493b9..2828ec2 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt @@ -25,8 +25,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import dev.kaizen.app.ui.effect.GlassSurface +import dev.kaizen.app.ui.effect.GlassTiers +import dev.kaizen.app.ui.effect.KaizenShadows +import dev.kaizen.app.ui.shape.KaizenShapes import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -55,6 +57,7 @@ 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.Oklab +import dev.kaizen.app.ui.theme.themes.KaizenThemeId /** Converts polar OKLCH coordinates (Lightness, Chroma, Hue in degrees) to a standard Color */ private fun oklch(l: Float, c: Float, h: Float, alpha: Float = 1.0f): Color { @@ -81,53 +84,14 @@ fun SettingsCard( ) { val isDark = isSystemInDarkTheme() - val glassBg = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color(0xFF1E293B).copy(alpha = 0.55f), // Slate 800 (55% opacity) - Color(0xFF0F172A).copy(alpha = 0.65f) // Slate 900 (65% opacity) - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.85f), - Color(0xFFEEF2F6).copy(alpha = 0.75f) // Warm white-slate (75% opacity) - ) - ) - } - } - - val glassBorder = remember(isDark) { - if (isDark) { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.16f), // Crisp glanz highlight top-left - Color.White.copy(alpha = 0.02f) // Faint dark shade bottom-right - ) - ) - } else { - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.60f), - Color.Black.copy(alpha = 0.05f) - ) - ) - } - } - - Box( - modifier = modifier - .fillMaxWidth() - .shadow(elevation = 8.dp, shape = RoundedCornerShape(24.dp), clip = false) // Floating 3D shadow - .clip(RoundedCornerShape(24.dp)) - .background(glassBg) - .border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) - .padding(18.dp) + GlassSurface( + modifier = modifier.fillMaxWidth(), + shape = KaizenShapes.lg, + tintAlpha = GlassTiers.card(isDark), + shadowStack = KaizenShadows.level2, + cornerRadius = 24.dp, ) { - // Standard Column provides standard ColumnScope receiver to the child layout content slot - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(18.dp)) { content() } } @@ -152,7 +116,7 @@ fun SettingsOptionRow( Box( modifier = Modifier .size(38.dp) - .clip(CircleShape) + .clip(KaizenShapes.circle) .background(cs.onBackground.copy(alpha = 0.06f)), contentAlignment = Alignment.Center ) { @@ -203,9 +167,9 @@ fun SettingsInputCapsule( Box( modifier = modifier .widthIn(max = 180.dp) - .clip(RoundedCornerShape(12.dp)) + .clip(KaizenShapes.sm) .background(inputGlassBg) - .border(1.dp, inputGlassBorder, RoundedCornerShape(12.dp)) + .border(1.dp, inputGlassBorder, KaizenShapes.sm) .padding(horizontal = 12.dp, vertical = 8.dp), contentAlignment = Alignment.CenterStart ) { @@ -238,17 +202,17 @@ fun ThemeOptionPill( val (colorStart, colorEnd) = remember(themeId, isDark) { if (isDark) { when (themeId) { - KaizenThemeId.Amber -> oklch(0.76f, 0.14f, 83f) to oklch(0.55f, 0.16f, 83f) - KaizenThemeId.Blue -> oklch(0.68f, 0.18f, 257f) to oklch(0.45f, 0.18f, 257f) - KaizenThemeId.Aurora -> oklch(0.78f, 0.15f, 85f) to oklch(0.45f, 0.18f, 320f) - KaizenThemeId.Mono -> oklch(0.92f, 0f, 255f) to oklch(0.35f, 0f, 255f) + KaizenThemeId.AMBER -> oklch(0.76f, 0.14f, 83f) to oklch(0.55f, 0.16f, 83f) + KaizenThemeId.BLAU -> oklch(0.68f, 0.18f, 257f) to oklch(0.45f, 0.18f, 257f) + KaizenThemeId.AURORA -> oklch(0.78f, 0.15f, 85f) to oklch(0.45f, 0.18f, 320f) + KaizenThemeId.MONO -> oklch(0.92f, 0f, 255f) to oklch(0.35f, 0f, 255f) } } else { when (themeId) { - KaizenThemeId.Amber -> oklch(0.72f, 0.14f, 83f) to oklch(0.85f, 0.15f, 83f) - KaizenThemeId.Blue -> oklch(0.62f, 0.19f, 257f) to oklch(0.78f, 0.15f, 257f) - KaizenThemeId.Aurora -> oklch(0.62f, 0.20f, 320f) to oklch(0.75f, 0.16f, 320f) - KaizenThemeId.Mono -> oklch(0.22f, 0f, 255f) to oklch(0.85f, 0f, 255f) + KaizenThemeId.AMBER -> oklch(0.72f, 0.14f, 83f) to oklch(0.85f, 0.15f, 83f) + KaizenThemeId.BLAU -> oklch(0.62f, 0.19f, 257f) to oklch(0.78f, 0.15f, 257f) + KaizenThemeId.AURORA -> oklch(0.62f, 0.20f, 320f) to oklch(0.75f, 0.16f, 320f) + KaizenThemeId.MONO -> oklch(0.22f, 0f, 255f) to oklch(0.85f, 0f, 255f) } } } @@ -262,8 +226,8 @@ fun ThemeOptionPill( Box( modifier = modifier .size(48.dp) - .shadow(elevation = 4.dp, shape = CircleShape, clip = false) - .clip(CircleShape) + .shadow(elevation = 4.dp, shape = KaizenShapes.circle, clip = false) + .clip(KaizenShapes.circle) .clickable { onClick() } .border( width = if (selected) 2.5.dp else 1.2.dp, @@ -272,10 +236,10 @@ fun ThemeOptionPill( } else { Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.35f), Color.White.copy(alpha = 0.05f))) }, - shape = CircleShape + shape = KaizenShapes.circle ) .padding(if (selected) 3.dp else 0.dp) // creates a beautiful inner gap for the selected ring - .clip(CircleShape) + .clip(KaizenShapes.circle) ) { // Draw the perception gradient on Canvas Canvas(Modifier.fillMaxSize()) { @@ -288,7 +252,7 @@ fun ThemeOptionPill( Icon( imageVector = Icons.Rounded.Check, contentDescription = null, - tint = if (themeId == KaizenThemeId.Mono && isDark.not()) Color.White else Color.Black, + tint = if (themeId == KaizenThemeId.MONO && isDark.not()) Color.White else Color.Black, modifier = Modifier.size(16.dp) ) } @@ -317,8 +281,8 @@ fun AppearanceTogglePill( Box( modifier = modifier - .shadow(elevation = if (selected) 4.dp else 0.dp, shape = CircleShape, clip = false) - .clip(CircleShape) + .shadow(elevation = if (selected) 4.dp else 0.dp, shape = KaizenShapes.circle, clip = false) + .clip(KaizenShapes.circle) .background( if (selected) { Amber.copy(alpha = 0.16f) @@ -333,7 +297,7 @@ fun AppearanceTogglePill( } else { glassBorder }, - shape = CircleShape + shape = KaizenShapes.circle ) .clickable { onClick() } .padding(horizontal = 16.dp, vertical = 9.dp), diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt index 55094f4..0bc4913 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt @@ -17,11 +17,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape +import dev.kaizen.app.ui.effect.GlassSurface +import dev.kaizen.app.ui.effect.GlassTiers +import dev.kaizen.app.ui.effect.KaizenShadows +import dev.kaizen.app.ui.shape.KaizenShapes import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Settings @@ -35,9 +39,9 @@ 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.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import dev.kaizen.app.R +import dev.kaizen.app.ui.theme.themes.KaizenThemeId import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -63,21 +67,7 @@ fun SettingsScreen( val appearanceMode by viewModel.appearanceMode.collectAsState() val userName by viewModel.userName.collectAsState() val userEmail by viewModel.userEmail.collectAsState() - - val backBtnGlassBg = remember(isDark) { - if (isDark) { - Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.65f), Color(0xFF0F172A).copy(alpha = 0.75f))) - } else { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFF1F5F9).copy(alpha = 0.75f))) - } - } - val backBtnGlassBorder = remember(isDark) { - if (isDark) { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f))) - } else { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.Black.copy(alpha = 0.05f))) - } - } + val locale by viewModel.locale.collectAsState() MeshBackground { Column( @@ -95,139 +85,70 @@ fun SettingsScreen( .padding(bottom = 24.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier - .shadow(elevation = 6.dp, shape = CircleShape, clip = false) - .clip(CircleShape) - .background(backBtnGlassBg) - .border(1.2.dp, backBtnGlassBorder, CircleShape) - .clickable { onBack() } - .size(44.dp), - contentAlignment = Alignment.Center + GlassSurface( + shape = KaizenShapes.circle, + tintAlpha = GlassTiers.pill(isDark), + shadowStack = KaizenShadows.level2, + cornerRadius = 22.dp, + modifier = Modifier.size(44.dp).clickable { onBack() }, ) { - Icon( - imageVector = Icons.Rounded.ArrowBack, - contentDescription = "Zurück", - tint = cs.onBackground, - modifier = Modifier.size(20.dp) - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Zurück", tint = cs.onBackground, modifier = Modifier.size(20.dp)) + } } Spacer(Modifier.width(16.dp)) - Text( - text = "Einstellungen", - color = cs.onBackground, - fontSize = 24.sp, - fontWeight = FontWeight.Bold - ) + Text(stringResource(R.string.settings_title), color = cs.onBackground, fontSize = 24.sp, fontWeight = FontWeight.Bold) } - // --- SEKTION 1: Erscheinungsbild (Theme & Appearance) --- - Text( - text = "ERSCHEINUNGSBILD", - color = cs.onSurfaceVariant.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp, bottom = 8.dp) - ) - + Text(stringResource(R.string.settings_section_appearance), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) + SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) { - // Theme Picker Row (Oklab perception space stops) - SettingsOptionRow( - title = "Akzentfarbe", - description = "Wähle das Farbschema der App (Oklab perzeptiv)", - icon = Icons.Rounded.Palette - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - KaizenThemeId.values().forEach { theme -> - ThemeOptionPill( - themeId = theme, - selected = selectedTheme == theme, - onClick = { viewModel.selectTheme(theme) } - ) + SettingsOptionRow(title = stringResource(R.string.settings_accent_title), description = stringResource(R.string.settings_accent_desc), icon = Icons.Rounded.Palette) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + KaizenThemeId.entries.forEach { theme -> + ThemeOptionPill(themeId = theme, selected = selectedTheme == theme, onClick = { viewModel.selectTheme(theme) }) } } } - Spacer(Modifier.height(12.dp)) - - // Light / Dark / System Appearance segmented toggle pills - SettingsOptionRow( - title = "Helligkeitsmodus", - description = "Passe das Hell/Dunkel-Erscheinungsbild an", - icon = Icons.Rounded.Settings - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AppAppearance.values().forEach { mode -> + SettingsOptionRow(title = stringResource(R.string.settings_appearance_title), description = stringResource(R.string.settings_appearance_desc), icon = Icons.Rounded.Settings) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + AppAppearance.entries.forEach { mode -> val label = when (mode) { - AppAppearance.Light -> "Hell" - AppAppearance.Dark -> "Dunkel" - AppAppearance.System -> "System" + AppAppearance.Light -> stringResource(R.string.settings_appearance_light) + AppAppearance.Dark -> stringResource(R.string.settings_appearance_dark) + AppAppearance.System -> stringResource(R.string.settings_appearance_system) } - AppearanceTogglePill( - selected = appearanceMode == mode, - label = label, - onClick = { viewModel.selectAppearance(mode) } - ) + AppearanceTogglePill(selected = appearanceMode == mode, label = label, onClick = { viewModel.selectAppearance(mode) }) + } + } + } + Spacer(Modifier.height(12.dp)) + SettingsOptionRow(title = stringResource(R.string.settings_language_title), description = stringResource(R.string.settings_language_desc), icon = Icons.Rounded.Language) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + listOf("system" to R.string.settings_language_system, "de" to R.string.settings_language_de, "en" to R.string.settings_language_en).forEach { (code, labelRes) -> + AppearanceTogglePill(selected = locale == code, label = stringResource(labelRes), onClick = { viewModel.selectLocale(code) }) } } } } - // --- SEKTION 2: Profil & Konto --- - Text( - text = "PROFIL & KONTO", - color = cs.onSurfaceVariant.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp, bottom = 8.dp) - ) - + Text(stringResource(R.string.settings_section_profile), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) + SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) { - // Username editor row - SettingsOptionRow( - title = "Benutzername", - description = "Ändere deinen Anzeigenamen", - icon = Icons.Rounded.Person - ) { - SettingsInputCapsule( - value = userName, - onValueChange = { viewModel.updateUserName(it) } - ) + SettingsOptionRow(title = stringResource(R.string.settings_name_title), description = stringResource(R.string.settings_name_desc), icon = Icons.Rounded.Person) { + SettingsInputCapsule(value = userName, onValueChange = { viewModel.updateUserName(it) }) } - Spacer(Modifier.height(12.dp)) - - // Email display row (read-only in this screen view) - SettingsOptionRow( - title = "E-Mail-Adresse", - description = userEmail, - icon = Icons.Rounded.Settings - ) + SettingsOptionRow(title = stringResource(R.string.settings_email_title), description = userEmail, icon = Icons.Rounded.Settings) } - // --- SEKTION 3: KI-Profil & Kontext --- - Text( - text = "KI-PROFIL & KONTEXT", - color = cs.onSurfaceVariant.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp, bottom = 8.dp) - ) - + Text(stringResource(R.string.settings_section_ai), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) + SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) { - SettingsOptionRow( - title = "System-Prompt & Fakten", - description = "Aktive Regeln: 12, Pinned: 3, Verwendetes Budget: 412 / 1500 Tokens", - icon = Icons.Rounded.AutoAwesome - ) + SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome) } } } diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt index a5f4bcc..403cd6b 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt @@ -1,67 +1,58 @@ package dev.kaizen.app.settings -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import dev.kaizen.app.net.SecureStore +import dev.kaizen.app.ui.theme.themes.KaizenThemeId import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -// Available Kaizen UI themes -enum class KaizenThemeId { Amber, Blue, Aurora, Mono } - -// Available Light/Dark/System appearance modes enum class AppAppearance { Light, Dark, System } -/** - * Clean MVVM ViewModel for the Settings module. - * - * Completely decoupled from Android UI classes, making it 100% unit-testable - * on the JVM. Manages the state of the Oklab Theme Picker, selected model, - * and user profile options. - */ -class SettingsViewModel : ViewModel() { +class SettingsViewModel(private val store: SecureStore? = null) : ViewModel() { - // 1. Reactive State Flow for Theme - private val _selectedTheme = MutableStateFlow(KaizenThemeId.Amber) + private val _selectedTheme = MutableStateFlow( + store?.loadThemeId()?.let { KaizenThemeId.fromString(it) } ?: KaizenThemeId.AMBER + ) val selectedTheme: StateFlow = _selectedTheme.asStateFlow() - // 2. Reactive State Flow for Appearance - private val _appearanceMode = MutableStateFlow(AppAppearance.Dark) + private val _appearanceMode = MutableStateFlow( + store?.loadAppearance()?.let { runCatching { AppAppearance.valueOf(it) }.getOrNull() } ?: AppAppearance.System + ) val appearanceMode: StateFlow = _appearanceMode.asStateFlow() - // 3. User profile details - private val _userName = MutableStateFlow("Bruno") + private val _userName = MutableStateFlow(store?.loadUserName() ?: "") val userName: StateFlow = _userName.asStateFlow() - private val _userEmail = MutableStateFlow("admin@kryptomrx.de") + private val _userEmail = MutableStateFlow("") val userEmail: StateFlow = _userEmail.asStateFlow() - // 4. Default Chat Model - private val _defaultModel = MutableStateFlow("gemini-2.5-flash") - val defaultModel: StateFlow = _defaultModel.asStateFlow() + private val _locale = MutableStateFlow(store?.loadLocale() ?: "system") + val locale: StateFlow = _locale.asStateFlow() - /** Selects a new Kaizen UI theme and triggers an Oklab transition stops recalculation */ fun selectTheme(themeId: KaizenThemeId) { _selectedTheme.value = themeId + store?.saveThemeId(themeId.value) } - /** Changes the appearance mode (Light, Dark, or System Sync) */ fun selectAppearance(mode: AppAppearance) { _appearanceMode.value = mode + store?.saveAppearance(mode.name) } - /** Updates the user's name in profile settings */ fun updateUserName(name: String) { if (name.isNotBlank()) { _userName.value = name.trim() + store?.saveUserName(name.trim()) } } - /** Updates the standard chat model */ - fun updateDefaultModel(modelId: String) { - if (modelId.isNotBlank()) { - _defaultModel.value = modelId.trim() - } + fun updateUserEmail(email: String) { + _userEmail.value = email + } + + fun selectLocale(locale: String) { + _locale.value = locale + store?.saveLocale(locale) } } diff --git a/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt b/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt new file mode 100644 index 0000000..2a252a1 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt @@ -0,0 +1,102 @@ +package dev.kaizen.app.ui.effect + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.kaizen.app.ui.shape.KaizenShapes + +/** + * A frosted-glass surface — the core visual primitive of the Kaizen design system. + * + * Android has no `backdrop-filter: blur()` — real refractive blur would require + * capturing and blurring the layer behind, which is not feasible in Compose's + * rendering model. Instead, the glass effect is achieved through: + * + * 1. Semi-transparent gradient tint (gives the surface body and color identity) + * 2. Specular border highlight (top-bright, bottom-dim gradient border) + * 3. Inner top highlight (1dp lit edge, simulates overhead light) + * 4. Multi-layer shadow stack (floating depth) + * + * The blob layer behind (MeshBackground) is what "shows through" the + * transparency, creating the frosted-glass illusion. + * + * See DESIGN.md §4 for the full anatomy and opacity tiers. + */ +@Composable +fun GlassSurface( + modifier: Modifier = Modifier, + shape: Shape = KaizenShapes.md, + tintAlpha: Float = 0.72f, + shadowStack: ShadowStack = KaizenShadows.level2, + cornerRadius: Dp = 16.dp, + content: @Composable BoxScope.() -> Unit, +) { + val isDark = isSystemInDarkTheme() + val cs = MaterialTheme.colorScheme + + val glassBg = remember(isDark, tintAlpha) { + if (isDark) { + Brush.verticalGradient( + listOf( + Color(0xFF1E293B).copy(alpha = tintAlpha), + Color(0xFF0F172A).copy(alpha = (tintAlpha + 0.10f).coerceAtMost(1f)), + ) + ) + } else { + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = tintAlpha), + Color(0xFFF1F5F9).copy(alpha = (tintAlpha - 0.05f).coerceAtLeast(0f)), + ) + ) + } + } + + val glassBorder = remember(isDark) { + if (isDark) { + Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f)) + ) + } else { + Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.55f), Color.Black.copy(alpha = 0.05f)) + ) + } + } + + Box( + modifier = modifier + .kaizenShadow(stack = shadowStack, cornerRadius = cornerRadius) + .clip(shape) + .background(glassBg) + .innerTopHighlight() + .border(1.2.dp, glassBorder, shape), + content = content, + ) +} + +/** + * Predefined opacity tiers matching DESIGN.md §4.4. + * Use these constants with [GlassSurface] instead of ad-hoc alpha values. + */ +object GlassTiers { + fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f + fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f + fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f + fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f + fun card(isDark: Boolean) = if (isDark) 0.94f else 0.95f + fun userBubble(isDark: Boolean) = if (isDark) 0.86f else 0.88f + fun sheet(isDark: Boolean) = if (isDark) 0.82f else 0.85f +} diff --git a/app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt b/app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt new file mode 100644 index 0000000..d1db6fd --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt @@ -0,0 +1,148 @@ +package dev.kaizen.app.ui.effect + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A single layer in a multi-layer shadow stack. + * + * Premium UI uses 3–5 overlapping shadow layers at increasing blur radii + * and decreasing opacity, producing a soft, realistic falloff that a + * single [Modifier.shadow] cannot match. + */ +data class ShadowLayer( + val offsetY: Dp, + val blur: Dp, + val spread: Dp = 0.dp, + val alpha: Float, +) + +/** + * A complete shadow stack of multiple [ShadowLayer]s. + */ +data class ShadowStack(val layers: List) + +/** + * Predefined shadow levels — use these everywhere instead of ad-hoc + * `Modifier.shadow(elevation = ...)`. + */ +object KaizenShadows { + + val level0 = ShadowStack(emptyList()) + + val level1 = ShadowStack( + listOf( + ShadowLayer(1.dp, 2.dp, 0.dp, 0.04f), + ShadowLayer(1.dp, 4.dp, 0.dp, 0.02f), + ) + ) + + val level2 = ShadowStack( + listOf( + ShadowLayer(2.dp, 4.dp, 0.dp, 0.05f), + ShadowLayer(4.dp, 12.dp, 0.dp, 0.04f), + ShadowLayer(8.dp, 24.dp, 0.dp, 0.03f), + ) + ) + + val level3 = ShadowStack( + listOf( + ShadowLayer(2.dp, 4.dp, 0.dp, 0.06f), + ShadowLayer(8.dp, 16.dp, 0.dp, 0.05f), + ShadowLayer(16.dp, 32.dp, 0.dp, 0.04f), + ShadowLayer(24.dp, 48.dp, 0.dp, 0.03f), + ) + ) + + val level4 = ShadowStack( + listOf( + ShadowLayer(4.dp, 8.dp, 0.dp, 0.07f), + ShadowLayer(12.dp, 24.dp, 0.dp, 0.06f), + ShadowLayer(24.dp, 48.dp, 0.dp, 0.05f), + ShadowLayer(48.dp, 96.dp, 0.dp, 0.04f), + ) + ) +} + +/** + * Draws a multi-layer shadow stack behind the content. + * + * [darkMultiplier] scales shadow alpha in dark mode (shadows need to be + * stronger on dark surfaces to read). Default 1.8× matches the DESIGN.md spec. + */ +@Composable +fun Modifier.kaizenShadow( + stack: ShadowStack, + cornerRadius: Dp = 16.dp, + darkMultiplier: Float = 1.8f, +): Modifier { + val isDark = isSystemInDarkTheme() + val effectiveMultiplier = if (isDark) darkMultiplier else 1f + val layers = remember(stack, effectiveMultiplier) { + stack.layers.map { layer -> + layer.copy(alpha = (layer.alpha * effectiveMultiplier).coerceAtMost(1f)) + } + } + return this.drawBehind { + drawShadowLayers(layers, cornerRadius) + } +} + +private fun DrawScope.drawShadowLayers(layers: List, cornerRadius: Dp) { + val cr = cornerRadius.toPx() + drawIntoCanvas { canvas -> + layers.forEach { layer -> + val paint = Paint().apply { + val framework = asFrameworkPaint() + framework.color = Color.Black.copy(alpha = layer.alpha).toArgb() + framework.setShadowLayer( + layer.blur.toPx(), + 0f, + layer.offsetY.toPx(), + Color.Black.copy(alpha = layer.alpha).toArgb(), + ) + } + val spreadPx = layer.spread.toPx() + canvas.drawRoundRect( + left = -spreadPx, + top = -spreadPx, + right = size.width + spreadPx, + bottom = size.height + spreadPx, + radiusX = cr, + radiusY = cr, + paint = paint, + ) + } + } +} + +/** + * Inner top highlight — 1dp inset white line that simulates "light from above." + * Together with the drop shadow, this makes glass surfaces look lit. + */ +@Composable +fun Modifier.innerTopHighlight(): Modifier { + val isDark = isSystemInDarkTheme() + val highlightAlpha = if (isDark) 0.08f else 0.95f + return this.drawBehind { + drawLine( + color = Color.White.copy(alpha = highlightAlpha), + start = Offset(0f, 1f), + end = Offset(size.width, 1f), + strokeWidth = 1.dp.toPx(), + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/ui/motion/Motion.kt b/app/src/main/java/dev/kaizen/app/ui/motion/Motion.kt new file mode 100644 index 0000000..74d82e7 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/motion/Motion.kt @@ -0,0 +1,52 @@ +package dev.kaizen.app.ui.motion + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween + +/** + * Kaizen animation primitives — single source of truth for all motion. + * + * Every motion in the app references these tokens. Defined here so the + * feel stays consistent and tuning is centralised. + */ + +// ── Easing Curves ────────────────────────────────────────────────────────── + +/** Overshoot ease — entrance animations */ +val EaseSpring = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) + +/** Expo-out — smooth transitions */ +val EaseSmooth = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) + +/** System-aligned standard */ +val EaseStandard = FastOutSlowInEasing + +/** M3 emphasized — page transitions */ +val EaseEmphasized = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + +// ── Spring Specs ─────────────────────────────────────────────────────────── + +/** Fast, punchy — squircle press, quick reactions */ +val SpringSnappy: SpringSpec = spring(dampingRatio = 0.55f, stiffness = 800f) + +/** Balanced — general state changes */ +val SpringDefault: SpringSpec = spring(dampingRatio = 0.75f, stiffness = 400f) + +/** Slow, fluid — tilt response, sidebar slide */ +val SpringGentle: SpringSpec = spring(dampingRatio = 0.85f, stiffness = 200f) + +// ── Duration Constants ───────────────────────────────────────────────────── + +object Durations { + const val DROPDOWN_OPEN = 150 + const val PAGE_TRANSITION = 280 + const val BUBBLE_ENTRANCE = 350 + const val THEME_CROSSFADE = 600 + const val ORB_BREATH = 7000 + const val ORB_BREATH_STREAMING = 1800 + const val THINKING_DOT_CYCLE = 600 + const val THINKING_DOT_PHASE_OFFSET = 160 +} diff --git a/app/src/main/java/dev/kaizen/app/ui/sensor/TiltState.kt b/app/src/main/java/dev/kaizen/app/ui/sensor/TiltState.kt new file mode 100644 index 0000000..9791d8e --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/sensor/TiltState.kt @@ -0,0 +1,145 @@ +package dev.kaizen.app.ui.sensor + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.PowerManager +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver + +/** + * Provides a smoothed tilt [Offset] (x, y in range -1..1) from the device sensors. + * + * Uses [Sensor.TYPE_GAME_ROTATION_VECTOR] (fused gyro + accel, pre-stabilized, + * no drift). Falls back to [Sensor.TYPE_GRAVITY] then [Sensor.TYPE_ACCELEROMETER]. + * + * Automatically disables when: + * - System animations are off (`ANIMATOR_DURATION_SCALE == 0`) + * - Battery saver is on + * - App is not in foreground + * - Device tilt exceeds ±25° (user lying down) + * + * See DESIGN.md §7 for full motion spec. + */ +@Composable +fun rememberTiltState(): State { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val tilt = remember { mutableStateOf(Offset.Zero) } + + DisposableEffect(lifecycle) { + val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + ?: return@DisposableEffect onDispose { } + + val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR) + ?: sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) + ?: sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + ?: return@DisposableEffect onDispose { } + + val isRotationVector = sensor.type == Sensor.TYPE_GAME_ROTATION_VECTOR + + val animScale = try { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE) + } catch (_: Exception) { 1f } + + if (animScale == 0f) return@DisposableEffect onDispose { } + + var filteredX = 0f + var filteredY = 0f + val alpha = 0.18f + var registered = false + + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent?) { + if (event == null) return + + val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + if (pm?.isPowerSaveMode == true) { + tilt.value = Offset.Zero + return + } + + val rawX: Float + val rawY: Float + + if (isRotationVector) { + val rotationMatrix = FloatArray(9) + SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) + val orientation = FloatArray(3) + SensorManager.getOrientation(rotationMatrix, orientation) + val pitch = Math.toDegrees(orientation[1].toDouble()).toFloat() + val roll = Math.toDegrees(orientation[2].toDouble()).toFloat() + + if (kotlin.math.abs(pitch) > 25f || kotlin.math.abs(roll) > 25f) { + tilt.value = Offset.Zero + return + } + + rawX = (roll / 25f).coerceIn(-1f, 1f) + rawY = (pitch / 25f).coerceIn(-1f, 1f) + } else { + val x = event.values[0] / 9.81f + val y = event.values[1] / 9.81f + + if (kotlin.math.abs(x) > 0.6f || kotlin.math.abs(y) > 0.6f) { + tilt.value = Offset.Zero + return + } + + rawX = x.coerceIn(-1f, 1f) + rawY = y.coerceIn(-1f, 1f) + } + + filteredX = filteredX + alpha * (rawX - filteredX) + filteredY = filteredY + alpha * (rawY - filteredY) + + tilt.value = Offset(filteredX, filteredY) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + } + + val lifecycleObserver = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + if (!registered) { + sensorManager.registerListener( + listener, sensor, SensorManager.SENSOR_DELAY_GAME + ) + registered = true + } + } + Lifecycle.Event.ON_STOP -> { + sensorManager.unregisterListener(listener) + registered = false + tilt.value = Offset.Zero + } + else -> {} + } + } + + lifecycle.addObserver(lifecycleObserver) + if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME) + registered = true + } + + onDispose { + lifecycle.removeObserver(lifecycleObserver) + if (registered) sensorManager.unregisterListener(listener) + } + } + + return tilt +} diff --git a/app/src/main/java/dev/kaizen/app/ui/shape/KaizenShapes.kt b/app/src/main/java/dev/kaizen/app/ui/shape/KaizenShapes.kt new file mode 100644 index 0000000..8bed09e --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/shape/KaizenShapes.kt @@ -0,0 +1,21 @@ +package dev.kaizen.app.ui.shape + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +/** + * Kaizen shape tokens — squircle-based replacement for Material Shapes. + * + * Every surface, button, chip, and bubble references one of these tokens. + * Centralised here so a single change propagates everywhere. + */ +object KaizenShapes { + val xs: Shape = SquircleShape(radius = 8.dp, smoothness = 4.0f) + val sm: Shape = SquircleShape(radius = 12.dp, smoothness = 4.2f) + val md: Shape = SquircleShape(radius = 16.dp, smoothness = 4.5f) + val lg: Shape = SquircleShape(radius = 24.dp, smoothness = 5.0f) + val xl: Shape = SquircleShape(radius = 28.dp, smoothness = 5.0f) + val pill: Shape = SquircleShape(radius = 999.dp, smoothness = 4.5f) + val circle: Shape = CircleShape +} diff --git a/app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt b/app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt new file mode 100644 index 0000000..01e6e12 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt @@ -0,0 +1,126 @@ +package dev.kaizen.app.ui.shape + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sign +import kotlin.math.sin + +/** + * iOS-style continuous superellipse ("squircle"). + * + * Unlike [RoundedCornerShape] which is a rectangle with quarter-circle arcs, + * a squircle has G2-continuous corners (curvature matches, not just position). + * The visual difference is subtle at small radii but unmistakable at 16+ dp — + * corners ease into the straight edges instead of snapping. + * + * The superellipse equation: |x/a|^n + |y/b|^n = 1 + * n = 2 → circle/ellipse + * n = 4–5 → iOS icon / Apple Liquid Glass sweet spot + * n → ∞ → rectangle + * + * [smoothness] controls the `n` exponent. Default 4.5 matches iOS 26. + * [samplesPerCorner] controls path resolution. 32 is invisible at any + * screen density; reduce to 16 for small shapes (< 32 dp). + */ +class SquircleShape( + private val radius: Dp, + private val smoothness: Float = 4.5f, + private val samplesPerCorner: Int = 32, +) : androidx.compose.ui.graphics.Shape { + + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val r = with(density) { radius.toPx() } + .coerceAtMost(min(size.width, size.height) / 2f) + + if (r <= 0f) return Outline.Rectangle( + androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height) + ) + + val path = Path().apply { + buildSquirclePath(size.width, size.height, r, smoothness, samplesPerCorner) + } + return Outline.Generic(path) + } + + override fun toString(): String = "SquircleShape(radius=$radius, smoothness=$smoothness)" +} + +/** + * Builds a closed squircle path into [this] Path. + * + * Traces the superellipse corner arcs and straight edges clockwise + * starting from the top-left corner's right edge. + */ +private fun Path.buildSquirclePath( + w: Float, + h: Float, + r: Float, + n: Float, + samples: Int, +) { + val step = (Math.PI / 2.0) / samples + + fun superX(angle: Double): Float { + val cosA = cos(angle) + return (abs(cosA).pow(2.0 / n) * sign(cosA) * r).toFloat() + } + + fun superY(angle: Double): Float { + val sinA = sin(angle) + return (abs(sinA).pow(2.0 / n) * sign(sinA) * r).toFloat() + } + + // Start at top edge, right of top-left corner + moveTo(r, 0f) + + // Top edge → top-right corner + lineTo(w - r, 0f) + for (i in 0..samples) { + val angle = Math.PI / 2.0 - step * i + val sx = superX(angle) + val sy = superY(angle) + lineTo(w - r + sx, r - sy) + } + + // Right edge → bottom-right corner + lineTo(w, h - r) + for (i in 0..samples) { + val angle = -step * i + val sx = superX(angle) + val sy = superY(angle) + lineTo(w - r + sx, h - r - sy) + } + + // Bottom edge → bottom-left corner + lineTo(r, h) + for (i in 0..samples) { + val angle = -(Math.PI / 2.0) - step * i + val sx = superX(angle) + val sy = superY(angle) + lineTo(r + sx, h - r - sy) + } + + // Left edge → top-left corner + lineTo(0f, r) + for (i in 0..samples) { + val angle = Math.PI - step * i + val sx = superX(angle) + val sy = superY(angle) + lineTo(r + sx, r - sy) + } + + close() +} diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt index 0fb7d98..9770115 100644 --- a/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt @@ -1,32 +1,47 @@ package dev.kaizen.app.ui.theme import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces -// Kaizen palette — Obsidian steel-blue + amber accent. -// OKLCH tokens from the web (globals.css) approximated to sRGB; fine-tuned on device. +// --------------------------------------------------------------------------- +// Kaizen Color Tokens — DisplayP3 pipeline +// --------------------------------------------------------------------------- +// Every brand color is declared in the Display P3 color space so the full +// gamut is used on wide-color displays (every Galaxy since 2018, every Pixel +// since the 4). On sRGB-only panels the runtime clamps gracefully. +// +// The sRGB hex approximations are in trailing comments for design handoff. +// Never use Color(0xFF…) for brand colors outside this file. +// --------------------------------------------------------------------------- -// Brand amber — kept constant across light/dark (orb, accents, send button). -val Amber = Color(0xFFE0A23E) // ~oklch(0.72 0.14 83) -val AmberDeep = Color(0xFFC8862A) -val OnAmber = Color(0xFF1B1206) +private fun p3(r: Float, g: Float, b: Float, a: Float = 1f): Color = + Color(red = r, green = g, blue = b, alpha = a, colorSpace = ColorSpaces.DisplayP3) -// Dark scheme (Obsidian) -val Obsidian = Color(0xFF0A0E16) // background ~oklch(0.10 0.025 245) -val ObsidianElevated = Color(0xFF141B27) // surface -val ObsidianInput = Color(0xFF111824) // input surface -val DarkText = Color(0xFFE8EBF1) -val DarkMuted = Color(0xFF8B94A4) -val DarkStroke = Color(0x16FFFFFF) // subtle light hairline +// ── Brand Amber (constant across all themes — they ARE the brand) ────────── +val Amber = p3(0.878f, 0.635f, 0.243f) // ≈ #E0A23E +val AmberDeep = p3(0.784f, 0.525f, 0.165f) // ≈ #C8862A +val OnAmber = p3(0.106f, 0.071f, 0.024f) // ≈ #1B1206 -// Light scheme -val LightBackground = Color(0xFFF6F7F9) -val LightSurface = Color(0xFFFFFFFF) -val LightSurfaceVariant = Color(0xFFEEF0F3) -val LightText = Color(0xFF1A1F29) -val LightMuted = Color(0xFF5B6470) -val LightStroke = Color(0x14000000) // subtle dark hairline +// ── Dark scheme — Obsidian Steel-Blue ────────────────────────────────────── +val Obsidian = p3(0.039f, 0.055f, 0.086f) // background ≈ #0A0E16 +val ObsidianElevated = p3(0.078f, 0.106f, 0.153f) // surface ≈ #141B27 +val ObsidianInput = p3(0.067f, 0.094f, 0.141f) // surfaceVar ≈ #111824 +val DarkText = p3(0.910f, 0.922f, 0.945f) // onBackground ≈ #E8EBF1 +val DarkMuted = p3(0.545f, 0.580f, 0.643f) // onSurfaceVar ≈ #8B94A4 +val DarkStroke = p3(1f, 1f, 1f, 0.09f) // outline -// Background blobs (.bg-blobs) -val BlobAmber = Color(0xFFE0A23E) -val BlobBlue = Color(0xFF3C5FD6) -val BlobTeal = Color(0xFF2BB6A6) +// ── Light scheme ─────────────────────────────────────────────────────────── +val LightBackground = p3(0.965f, 0.969f, 0.976f) // ≈ #F6F7F9 +val LightSurface = p3(1.000f, 1.000f, 1.000f) // ≈ #FFFFFF +val LightSurfaceVariant = p3(0.933f, 0.941f, 0.953f) // ≈ #EEF0F3 +val LightText = p3(0.102f, 0.122f, 0.161f) // ≈ #1A1F29 +val LightMuted = p3(0.357f, 0.392f, 0.439f) // ≈ #5B6470 +val LightStroke = p3(0f, 0f, 0f, 0.08f) // outline + +// ── Background blobs (Amber theme defaults, derived from amber.css) ──────── +// These are the light-mode defaults. Theme-specific blobs come from +// LocalKaizenAccent.current.blob1..4 once the theme system is wired. +val BlobAmber = p3(0.992f, 0.767f, 0.282f) // oklch(0.85 0.15 83) gold +val BlobCoral = p3(1.000f, 0.562f, 0.230f) // oklch(0.78 0.18 50) coral +val BlobHoney = p3(0.917f, 0.853f, 0.471f) // oklch(0.88 0.12 100) honey +val BlobTeal = p3(0.461f, 0.885f, 0.908f) // oklch(0.85 0.10 200) teal accent diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Oklab.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Oklab.kt index 78e52a3..f0b5972 100644 --- a/app/src/main/java/dev/kaizen/app/ui/theme/Oklab.kt +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Oklab.kt @@ -2,110 +2,86 @@ package dev.kaizen.app.ui.theme import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.colorspace.ColorSpaces +import kotlin.math.cbrt +import kotlin.math.pow -/** - * High-performance Oklab and Oklch (polar Oklab) color interpolation library. - * - * Pre-calculates linear perception color spaces to completely eliminate sRGB - * "muddy gray dead zones" in gradients, providing butter-smooth, luminous, and - * vibrant color transitions across all Android displays (from SDR to Display P3 / HDR). - */ object Oklab { - private fun toLinear(c: Float): Float { - return if (c <= 0.04045f) c / 12.92f else Math.pow(((c + 0.055) / 1.055), 2.4).toFloat() - } + private fun toLinear(c: Double): Double = + if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4) - private fun toSRGB(c: Float): Float { - val cl = c.coerceIn(0f, 1f) - return if (cl <= 0.0031308f) cl * 12.92f else (1.055f * Math.pow(cl.toDouble(), 1.0 / 2.4) - 0.055f).toFloat() + private fun fromLinear(c: Double): Float { + val cl = c.coerceIn(0.0, 1.0) + return if (cl <= 0.0031308) (cl * 12.92).toFloat() + else (1.055 * cl.pow(1.0 / 2.4) - 0.055).toFloat() } data class Lab(val l: Float, val a: Float, val b: Float) - /** Converts standard Compose Color (sRGB or P3) to Oklab space coordinates */ fun Color.toOklab(): Lab { - // Convert to linear RGB - val lr = toLinear(red) - val lg = toLinear(green) - val lb = toLinear(blue) + val lr = toLinear(red.toDouble()) + val lg = toLinear(green.toDouble()) + val lb = toLinear(blue.toDouble()) - // Linear RGB to LMS - val l = 0.4122214708f * lr + 0.5363325363f * lg + 0.0514459929f * lb - val m = 0.1167116119f * lr + 0.6858151024f * lg + 0.1974732856f * lb - val s = 0.0883315616f * lr + 0.1117281988f * lg + 0.7999602417f * lb + val l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb + val m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb + val s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb - // LMS to non-linear scaled LMS - val l_ = Math.cbrt(l.toDouble()).toFloat() - val m_ = Math.cbrt(m.toDouble()).toFloat() - val s_ = Math.cbrt(s.toDouble()).toFloat() + val lc = cbrt(l) + val mc = cbrt(m) + val sc = cbrt(s) - // Scaled LMS to Oklab (L, a, b) - val oklabL = 0.2104542553f * l_ + 0.7936177850f * m_ - 0.0040720468f * s_ - val oklabA = 1.9779984951f * l_ - 2.4285922050f * m_ + 0.4505937099f * s_ - val oklabB = 0.0259040371f * l_ + 0.7827717662f * m_ - 0.8086757660f * s_ - - return Lab(oklabL, oklabA, oklabB) + return Lab( + l = (0.2104542553 * lc + 0.7936177850 * mc - 0.0040720468 * sc).toFloat(), + a = (1.9779984951 * lc - 2.4285922050 * mc + 0.4505937099 * sc).toFloat(), + b = (0.0259040371 * lc + 0.7827717662 * mc - 0.8086757660 * sc).toFloat(), + ) } - /** Converts Oklab coordinates back to standard Compose Color in DisplayP3 or sRGB space */ - fun Lab.toColor(targetColorSpace: androidx.compose.ui.graphics.colorspace.ColorSpace = ColorSpaces.Srgb): Color { - // Oklab to scaled LMS - val l_ = l + 0.3963377774f * a + 0.2158037573f * b - val m_ = l - 0.1055613458f * a - 0.0638541728f * b - val s_ = l - 0.0894841775f * a - 1.2914855480f * b + fun Lab.toColor(): Color { + val ld = l.toDouble() + val ad = a.toDouble() + val bd = b.toDouble() - // Scaled LMS to LMS (cube) - val l = l_ * l_ * l_ - val m = m_ * m_ * m_ - val s = s_ * s_ * s_ + val lp = ld + 0.3963377774 * ad + 0.2158037573 * bd + val mp = ld - 0.1055613458 * ad - 0.0638541728 * bd + val sp = ld - 0.0894841775 * ad - 1.2914855480 * bd - // LMS to linear RGB - val lr = 4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s - val lg = -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s - val lb = -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s + val lc = lp * lp * lp + val mc = mp * mp * mp + val sc = sp * sp * sp - // Linear RGB to non-linear standard sRGB - val r = toSRGB(lr) - val g = toSRGB(lg) - val b = toSRGB(lb) + val r = +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc + val g = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc + val b = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc - return Color(red = r, green = g, blue = b, alpha = 1.0f, colorSpace = targetColorSpace) + return Color(red = fromLinear(r), green = fromLinear(g), blue = fromLinear(b), alpha = 1.0f) + } + + fun Lab.toColor(targetColorSpace: androidx.compose.ui.graphics.colorspace.ColorSpace): Color { + val base = toColor() + return Color(red = base.red, green = base.green, blue = base.blue, alpha = 1.0f, + colorSpace = targetColorSpace) } - /** Linearly interpolates two colors inside the perzeptive Oklab color space */ fun interpolate(from: Color, to: Color, fraction: Float): Color { - val oklabFrom = from.toOklab() - val oklabTo = to.toOklab() + val f = from.toOklab() + val t = to.toOklab() - val interpolatedL = oklabFrom.l + (oklabTo.l - oklabFrom.l) * fraction - val interpolatedA = oklabFrom.a + (oklabTo.a - oklabFrom.a) * fraction - val interpolatedB = oklabFrom.b + (oklabTo.b - oklabFrom.b) * fraction + val lab = Lab( + l = f.l + (t.l - f.l) * fraction, + a = f.a + (t.a - f.a) * fraction, + b = f.b + (t.b - f.b) * fraction, + ) - val interpolatedAlpha = from.alpha + (to.alpha - from.alpha) * fraction - - // Match the target color space (prefer DisplayP3 if either color is P3) - val targetSpace = if (from.colorSpace == ColorSpaces.DisplayP3 || to.colorSpace == ColorSpaces.DisplayP3) { - ColorSpaces.DisplayP3 - } else { - ColorSpaces.Srgb - } - - return Lab(interpolatedL, interpolatedA, interpolatedB).toColor(targetSpace).copy(alpha = interpolatedAlpha) + val useP3 = from.colorSpace == ColorSpaces.DisplayP3 || to.colorSpace == ColorSpaces.DisplayP3 + val color = if (useP3) lab.toColor(ColorSpaces.DisplayP3) else lab.toColor() + return color.copy(alpha = from.alpha + (to.alpha - from.alpha) * fraction) } - /** - * Generates a list of interpolated Color stops in Oklab space. - * - * Perfect for constructing smooth Sweep, Radial, or Linear gradients that feel - * incredibly rich and high-fidelity on high-end device screens (S25 Ultra, Pixel 10). - */ fun generateGradientStops(from: Color, to: Color, steps: Int = 12): List { - val list = ArrayList(steps) - for (i in 0 until steps) { - val fraction = i.toFloat() / (steps - 1).toFloat() - list.add(interpolate(from, to, fraction)) + return List(steps) { i -> + interpolate(from, to, i.toFloat() / (steps - 1).toFloat()) } - return list } } diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt index 54fa610..381bfe8 100644 --- a/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Theme.kt @@ -5,14 +5,29 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import dev.kaizen.app.ui.theme.themes.AmberTheme +import dev.kaizen.app.ui.theme.themes.KaizenAccentScheme +import dev.kaizen.app.ui.theme.themes.KaizenThemeId +import dev.kaizen.app.ui.theme.themes.KaizenThemeSpec +import dev.kaizen.app.ui.theme.themes.ThemeRegistry -// Fixed Kaizen schemes (no dynamic color) so the glass look stays consistent. -private val KaizenDark = darkColorScheme( - primary = Amber, - onPrimary = OnAmber, - secondary = Amber, - onSecondary = OnAmber, +/** + * CompositionLocal for the active Kaizen accent scheme. + * + * Use `LocalKaizenAccent.current` to access theme-specific colors (primary, + * blobs, ring) that go beyond MaterialTheme.colorScheme. This is the bridge + * between the four Kaizen themes and the Material3 color system. + */ +val LocalKaizenAccent = staticCompositionLocalOf { AmberTheme.dark } + +private fun buildDarkScheme(accent: KaizenAccentScheme) = darkColorScheme( + primary = accent.primary, + onPrimary = accent.onPrimary, + secondary = accent.primary, + onSecondary = accent.onPrimary, background = Obsidian, onBackground = DarkText, surface = ObsidianElevated, @@ -22,11 +37,11 @@ private val KaizenDark = darkColorScheme( outline = DarkStroke, ) -private val KaizenLight = lightColorScheme( - primary = AmberDeep, - onPrimary = Color.White, - secondary = AmberDeep, - onSecondary = Color.White, +private fun buildLightScheme(accent: KaizenAccentScheme) = lightColorScheme( + primary = accent.primary, + onPrimary = accent.onPrimary, + secondary = accent.primary, + onSecondary = accent.onPrimary, background = LightBackground, onBackground = LightText, surface = LightSurface, @@ -38,12 +53,19 @@ private val KaizenLight = lightColorScheme( @Composable fun KaizenTheme( + themeId: KaizenThemeId = KaizenThemeId.AMBER, darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { - MaterialTheme( - colorScheme = if (darkTheme) KaizenDark else KaizenLight, - typography = Typography, - content = content, - ) + val spec = ThemeRegistry.forId(themeId) + val accent = if (darkTheme) spec.dark else spec.light + val colorScheme = if (darkTheme) buildDarkScheme(accent) else buildLightScheme(accent) + + CompositionLocalProvider(LocalKaizenAccent provides accent) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) + } } diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Type.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Type.kt index bad12c1..73ad3db 100644 --- a/app/src/main/java/dev/kaizen/app/ui/theme/Type.kt +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Type.kt @@ -2,33 +2,123 @@ package dev.kaizen.app.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import dev.kaizen.app.R + +@OptIn(androidx.compose.ui.text.ExperimentalTextApi::class) +val InterVariable = FontFamily( + Font( + R.font.inter_variable, + weight = FontWeight.W100, + variationSettings = FontVariation.Settings(FontVariation.weight(100)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W200, + variationSettings = FontVariation.Settings(FontVariation.weight(200)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W300, + variationSettings = FontVariation.Settings(FontVariation.weight(300)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W400, + variationSettings = FontVariation.Settings(FontVariation.weight(400)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W500, + variationSettings = FontVariation.Settings(FontVariation.weight(500)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W600, + variationSettings = FontVariation.Settings(FontVariation.weight(600)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W700, + variationSettings = FontVariation.Settings(FontVariation.weight(700)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W800, + variationSettings = FontVariation.Settings(FontVariation.weight(800)), + ), + Font( + R.font.inter_variable, + weight = FontWeight.W900, + variationSettings = FontVariation.Settings(FontVariation.weight(900)), + ), +) -// Set of Material typography styles to start with val Typography = Typography( + displayLarge = TextStyle( + fontFamily = InterVariable, + fontWeight = FontWeight.W600, + fontSize = 32.sp, + lineHeight = 38.sp, + letterSpacing = (-0.32).sp, + ), + displayMedium = TextStyle( + fontFamily = InterVariable, + fontWeight = FontWeight.W600, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.12).sp, + ), + titleLarge = TextStyle( + fontFamily = InterVariable, + fontWeight = FontWeight.W600, + fontSize = 20.sp, + lineHeight = 26.sp, + ), + titleMedium = TextStyle( + fontFamily = InterVariable, + fontWeight = FontWeight.W500, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = 0.05.sp, + ), bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, + fontFamily = InterVariable, + fontWeight = FontWeight.W400, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp + letterSpacing = 0.08.sp, + ), + bodyMedium = TextStyle( + fontFamily = InterVariable, + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.11.sp, + ), + labelLarge = TextStyle( + fontFamily = InterVariable, + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.14.sp, + ), + labelMedium = TextStyle( + fontFamily = InterVariable, + fontWeight = FontWeight.W500, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.24.sp, ), labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, + fontFamily = InterVariable, + fontWeight = FontWeight.W500, fontSize = 11.sp, lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file + letterSpacing = 0.33.sp, + ), +) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/themes/AccentScheme.kt b/app/src/main/java/dev/kaizen/app/ui/theme/themes/AccentScheme.kt new file mode 100644 index 0000000..188b9bc --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/themes/AccentScheme.kt @@ -0,0 +1,47 @@ +package dev.kaizen.app.ui.theme.themes + +import androidx.compose.ui.graphics.Color + +/** + * The accent portion of a Kaizen theme. + * + * Neutrals (background, surface, text) are theme-independent — same Obsidian / + * Light off-white across all four themes. Only the accent + blob colors swap. + * This matches the web architecture where `globals.css` ships neutrals and + * `app/themes/.css` ships only accent tokens. + */ +data class KaizenAccentScheme( + val primary: Color, + val primaryDeep: Color, + val onPrimary: Color, + val ring: Color, + val blob1: Color, + val blob2: Color, + val blob3: Color, + val blob4: Color, +) + +/** + * Theme identity — server-synced via `/api/v1/me`. + */ +enum class KaizenThemeId(val value: String) { + AMBER("amber"), + BLAU("blue"), + MONO("mono"), + AURORA("aurora"); + + companion object { + fun fromString(s: String): KaizenThemeId = + entries.firstOrNull { it.value == s } ?: AMBER + } +} + +/** + * A complete Kaizen theme — light + dark accent schemes. + */ +data class KaizenThemeSpec( + val id: KaizenThemeId, + val label: String, + val light: KaizenAccentScheme, + val dark: KaizenAccentScheme, +) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/themes/AmberTheme.kt b/app/src/main/java/dev/kaizen/app/ui/theme/themes/AmberTheme.kt new file mode 100644 index 0000000..91e562c --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/themes/AmberTheme.kt @@ -0,0 +1,33 @@ +package dev.kaizen.app.ui.theme.themes + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces + +private fun p3(r: Float, g: Float, b: Float): Color = + Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3) + +// Derived from kaizen/app/themes/amber.css — warm blobs (gold, coral, honey, teal accent) +val AmberTheme = KaizenThemeSpec( + id = KaizenThemeId.AMBER, + label = "Amber", + light = KaizenAccentScheme( + primary = p3(0.811f, 0.608f, 0.126f), // oklch(0.72 0.14 83) + primaryDeep = p3(0.811f, 0.608f, 0.126f), + onPrimary = p3(0.015f, 0.013f, 0.009f), // oklch(0.10 0.005 83) + ring = p3(0.811f, 0.608f, 0.126f), + blob1 = p3(0.992f, 0.767f, 0.282f), // oklch(0.85 0.15 83) gold + blob2 = p3(1.000f, 0.562f, 0.230f), // oklch(0.78 0.18 50) coral + blob3 = p3(0.917f, 0.853f, 0.471f), // oklch(0.88 0.12 100) honey + blob4 = p3(0.461f, 0.885f, 0.908f), // oklch(0.85 0.10 200) teal accent + ), + dark = KaizenAccentScheme( + primary = p3(0.862f, 0.658f, 0.201f), // oklch(0.76 0.14 83) + primaryDeep = p3(0.811f, 0.608f, 0.126f), + onPrimary = p3(0.015f, 0.013f, 0.009f), // oklch(0.10 0.005 83) + ring = p3(0.862f, 0.658f, 0.201f), + blob1 = p3(0.619f, 0.394f, 0.000f), // oklch(0.55 0.16 83) deep gold + blob2 = p3(0.628f, 0.087f, 0.000f), // oklch(0.45 0.18 40) deep coral + blob3 = p3(0.404f, 0.407f, 0.084f), // oklch(0.50 0.10 110) olive + blob4 = p3(0.000f, 0.405f, 0.436f), // oklch(0.45 0.12 200) deep teal + ), +) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/themes/AuroraTheme.kt b/app/src/main/java/dev/kaizen/app/ui/theme/themes/AuroraTheme.kt new file mode 100644 index 0000000..4cd332f --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/themes/AuroraTheme.kt @@ -0,0 +1,35 @@ +package dev.kaizen.app.ui.theme.themes + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces + +private fun p3(r: Float, g: Float, b: Float): Color = + Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3) + +// Derived from kaizen/app/themes/aurora.css +// Light: purple primary, blobs = purple/gold/green/teal +// Dark: GOLD primary (swaps!), blobs = deep purple/gold/green/teal +val AuroraTheme = KaizenThemeSpec( + id = KaizenThemeId.AURORA, + label = "Aurora", + light = KaizenAccentScheme( + primary = p3(0.724f, 0.328f, 0.812f), // oklch(0.62 0.20 320) purple + primaryDeep = p3(0.724f, 0.328f, 0.812f), + onPrimary = p3(1.000f, 0.979f, 1.000f), // oklch(0.99 0.01 320) + ring = p3(0.724f, 0.328f, 0.812f), + blob1 = p3(0.855f, 0.540f, 0.926f), // oklch(0.75 0.16 320) purple + blob2 = p3(0.931f, 0.738f, 0.291f), // oklch(0.82 0.14 85) gold + blob3 = p3(0.433f, 0.822f, 0.455f), // oklch(0.78 0.16 145) green + blob4 = p3(0.227f, 0.808f, 0.837f), // oklch(0.78 0.12 200) teal + ), + dark = KaizenAccentScheme( + primary = p3(0.890f, 0.684f, 0.156f), // oklch(0.78 0.15 85) GOLD (swapped!) + primaryDeep = p3(0.724f, 0.328f, 0.812f), + onPrimary = p3(0.034f, 0.020f, 0.003f), // oklch(0.12 0.02 85) + ring = p3(0.890f, 0.684f, 0.156f), + blob1 = p3(0.495f, 0.141f, 0.569f), // oklch(0.45 0.18 320) deep purple + blob2 = p3(0.600f, 0.405f, 0.000f), // oklch(0.55 0.15 85) deep gold + blob3 = p3(0.000f, 0.459f, 0.000f), // oklch(0.48 0.18 145) deep green + blob4 = p3(0.000f, 0.410f, 0.445f), // oklch(0.45 0.13 200) deep teal + ), +) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/themes/BlauTheme.kt b/app/src/main/java/dev/kaizen/app/ui/theme/themes/BlauTheme.kt new file mode 100644 index 0000000..7d95dc1 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/themes/BlauTheme.kt @@ -0,0 +1,33 @@ +package dev.kaizen.app.ui.theme.themes + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces + +private fun p3(r: Float, g: Float, b: Float): Color = + Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3) + +// Derived from kaizen/app/themes/blue.css — cool blobs (sky, cyan, indigo, warm peach accent) +val BlauTheme = KaizenThemeSpec( + id = KaizenThemeId.BLAU, + label = "Blau", + light = KaizenAccentScheme( + primary = p3(0.168f, 0.513f, 0.962f), // oklch(0.62 0.19 257) + primaryDeep = p3(0.168f, 0.513f, 0.962f), + onPrimary = p3(0.971f, 0.989f, 1.000f), // oklch(0.99 0.01 257) + ring = p3(0.168f, 0.513f, 0.962f), + blob1 = p3(0.464f, 0.725f, 1.000f), // oklch(0.78 0.15 257) sky + blob2 = p3(0.000f, 0.766f, 0.953f), // oklch(0.75 0.16 220) cyan + blob3 = p3(0.648f, 0.551f, 1.000f), // oklch(0.72 0.18 290) indigo + blob4 = p3(0.973f, 0.763f, 0.519f), // oklch(0.85 0.10 70) peach accent + ), + dark = KaizenAccentScheme( + primary = p3(0.276f, 0.592f, 1.000f), // oklch(0.68 0.18 257) + primaryDeep = p3(0.168f, 0.513f, 0.962f), + onPrimary = p3(0.010f, 0.023f, 0.051f), // oklch(0.12 0.02 257) + ring = p3(0.276f, 0.592f, 1.000f), + blob1 = p3(0.000f, 0.307f, 0.713f), // oklch(0.45 0.18 257) deep sky + blob2 = p3(0.000f, 0.360f, 0.533f), // oklch(0.42 0.16 220) deep cyan + blob3 = p3(0.305f, 0.111f, 0.660f), // oklch(0.40 0.20 290) deep indigo + blob4 = p3(0.580f, 0.315f, 0.000f), // oklch(0.50 0.14 70) amber accent + ), +) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/themes/MonoTheme.kt b/app/src/main/java/dev/kaizen/app/ui/theme/themes/MonoTheme.kt new file mode 100644 index 0000000..c727004 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/themes/MonoTheme.kt @@ -0,0 +1,33 @@ +package dev.kaizen.app.ui.theme.themes + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces + +private fun p3(r: Float, g: Float, b: Float): Color = + Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3) + +// Derived from kaizen/app/themes/mono.css — achromatic, zero chroma, subtle warm blob4 accent +val MonoTheme = KaizenThemeSpec( + id = KaizenThemeId.MONO, + label = "Mono", + light = KaizenAccentScheme( + primary = p3(0.104f, 0.104f, 0.104f), // oklch(0.22 0 255) near-black + primaryDeep = p3(0.104f, 0.104f, 0.104f), + onPrimary = p3(0.987f, 0.987f, 0.987f), // oklch(0.99 0 255) near-white + ring = p3(0.104f, 0.104f, 0.104f), + blob1 = p3(0.806f, 0.806f, 0.806f), // oklch(0.85 0 255) light gray + blob2 = p3(0.681f, 0.681f, 0.681f), // oklch(0.75 0 255) mid gray + blob3 = p3(0.896f, 0.896f, 0.896f), // oklch(0.92 0 255) off-white + blob4 = p3(0.796f, 0.765f, 0.714f), // oklch(0.82 0.02 80) subtle warm + ), + dark = KaizenAccentScheme( + primary = p3(0.896f, 0.896f, 0.896f), // oklch(0.92 0 255) near-white + primaryDeep = p3(0.806f, 0.806f, 0.806f), + onPrimary = p3(0.022f, 0.022f, 0.022f), // oklch(0.12 0 255) near-black + ring = p3(0.896f, 0.896f, 0.896f), + blob1 = p3(0.229f, 0.229f, 0.229f), // oklch(0.35 0 255) dark gray + blob2 = p3(0.334f, 0.334f, 0.334f), // oklch(0.45 0 255) mid-dark gray + blob3 = p3(0.179f, 0.179f, 0.179f), // oklch(0.30 0 255) charcoal + blob4 = p3(0.304f, 0.277f, 0.234f), // oklch(0.40 0.02 80) subtle warm + ), +) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/themes/ThemeRegistry.kt b/app/src/main/java/dev/kaizen/app/ui/theme/themes/ThemeRegistry.kt new file mode 100644 index 0000000..060233e --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/themes/ThemeRegistry.kt @@ -0,0 +1,18 @@ +package dev.kaizen.app.ui.theme.themes + +/** + * Central registry of all available themes. + * + * Adding a new theme = create a `*Theme.kt` file + add it here. + */ +object ThemeRegistry { + val all: List = listOf( + AmberTheme, + BlauTheme, + MonoTheme, + AuroraTheme, + ) + + fun forId(id: KaizenThemeId): KaizenThemeSpec = + all.firstOrNull { it.id == id } ?: AmberTheme +} diff --git a/app/src/main/res/drawable-night/ic_launcher_background.xml b/app/src/main/res/drawable-night/ic_launcher_background.xml new file mode 100644 index 0000000..75bd836 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_launcher_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..9a5aa6e 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..3f8dfb4 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,91 @@ + - + + + - - + android:type="radial" + android:centerX="54" + android:centerY="54" + android:gradientRadius="30"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + android:pathData="M54,54m-20.6,0a20.6,20.6 0,1 1,41.2 0a20.6,20.6 0,1 1,-41.2 0" + android:strokeWidth="0.6" + android:strokeColor="#25FFFFFF" + android:fillColor="#00000000" /> + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..3f54aaa --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/font/inter_variable.ttf b/app/src/main/res/font/inter_variable.ttf new file mode 100644 index 0000000..4ab79e0 Binary files /dev/null and b/app/src/main/res/font/inter_variable.ttf differ diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml index 6f3b755..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -2,5 +2,5 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml index 6f3b755..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -2,5 +2,5 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d6..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611d..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a307..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a695..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d642..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae3..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..faebf64 --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,103 @@ + + + Good Morning + Good Afternoon + Good Evening + Hello + What\'s up? + + + Type a message … + No chats yet + Locked Chat + Locked + New Chat + Search… + Attachment + Call + Remove + Error + Open menu + + + Reasoning + Sources + + + Session expired. Please sign in again. + Credits exhausted. + Too many requests. Please wait. + Server error (%1$d). + No connection to server. + + + Standard + Sampling + Image + Search + Video + Audio + + + Brainstorm + Let\'s brainstorm on a topic. + Generate Image + Generate an image of a mountain lake at sunrise. + Create Video + Create a short video of a city at night. + Search the Web + Search the web for the latest AI news. + + + kaizen + ★ PINNED + TODAY + YESTERDAY + EARLIER + Sign Out + Settings + + + Select Model + Search models… + Clear + Connecting… + No matches + Favorite + Selected + ★ Favorites + + + Welcome + Sign in to your Kaizen instance + https://your-instance.com + Email + Password + Sign In + Invalid email or password. + Too many attempts. Please wait. + Connection failed. Check server address. + + + Settings + Back + APPEARANCE + PROFILE & ACCOUNT + AI PROFILE & CONTEXT + Accent Color + Choose the app color scheme + Appearance + Set light/dark appearance + Display Name + Change your display name + Email Address + System Prompt & Facts + Light + Dark + System + Language + Choose the app language + System + Deutsch + English + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a679dc3..7866a1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,105 @@ Kaizen - \ No newline at end of file + + + Guten Morgen + Guten Tag + Guten Abend + Hallo + Was liegt an? + + + Nachricht eingeben … + Noch keine Chats + Gesperrter Chat + Gesperrt + Neuer Chat + Suchen… + Anhang + Anruf + Entfernen + Fehler + Menü öffnen + + + Gedankengang + Quellen + + + Sitzung abgelaufen. Bitte erneut anmelden. + Guthaben aufgebraucht. + Zu viele Anfragen. Kurz warten. + Fehler vom Server (%1$d). + Keine Verbindung zum Server. + + + Standard + Sampling + Bild + Suche + Video + Audio + + + Brainstormen + Lass uns zu einem Thema brainstormen. + Bild generieren + Generiere ein Bild von einem Bergsee bei Sonnenaufgang. + Video erstellen + Erstelle ein kurzes Video von einer Stadt bei Nacht. + Web durchsuchen + Durchsuche das Web nach den neuesten KI-Nachrichten. + + + kaizen + ★ ANGEHEFTET + HEUTE + GESTERN + FRÜHER + Abmelden + Einstellungen + + + Modell auswählen + Modell suchen… + Löschen + Verbindung wird hergestellt… + Keine Treffer + Favorit + Ausgewählt + ★ Favoriten + + + Willkommen + Melde dich bei deiner Kaizen-Instanz an + https://deine-instanz.de + E-Mail + Passwort + Anmelden + E-Mail oder Passwort ist falsch. + Zu viele Versuche. Bitte kurz warten. + Verbindung fehlgeschlagen. Server-Adresse prüfen. + + + Einstellungen + Zurück + ERSCHEINUNGSBILD + PROFIL & KONTO + KI-PROFIL & KONTEXT + Akzentfarbe + Wähle das Farbschema der App + Helligkeitsmodus + Passe das Hell/Dunkel-Erscheinungsbild an + Benutzername + Ändere deinen Anzeigenamen + E-Mail-Adresse + System-Prompt & Fakten + Hell + Dunkel + System + Sprache + Wähle die App-Sprache + System + Deutsch + English + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 8881f53..3d28c2f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,10 +1,11 @@ - diff --git a/app/src/test/java/dev/kaizen/app/ColorPipelineTest.kt b/app/src/test/java/dev/kaizen/app/ColorPipelineTest.kt new file mode 100644 index 0000000..5e87d43 --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/ColorPipelineTest.kt @@ -0,0 +1,86 @@ +package dev.kaizen.app + +import androidx.compose.ui.graphics.colorspace.ColorSpaces +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.BlobCoral +import dev.kaizen.app.ui.theme.BlobHoney +import dev.kaizen.app.ui.theme.BlobTeal +import dev.kaizen.app.ui.theme.DarkMuted +import dev.kaizen.app.ui.theme.DarkStroke +import dev.kaizen.app.ui.theme.DarkText +import dev.kaizen.app.ui.theme.LightBackground +import dev.kaizen.app.ui.theme.LightMuted +import dev.kaizen.app.ui.theme.LightStroke +import dev.kaizen.app.ui.theme.LightSurface +import dev.kaizen.app.ui.theme.LightSurfaceVariant +import dev.kaizen.app.ui.theme.LightText +import dev.kaizen.app.ui.theme.Obsidian +import dev.kaizen.app.ui.theme.ObsidianElevated +import dev.kaizen.app.ui.theme.ObsidianInput +import dev.kaizen.app.ui.theme.OnAmber +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ColorPipelineTest { + + @Test + fun `brand amber is in DisplayP3 color space`() { + assertEquals(ColorSpaces.DisplayP3, Amber.colorSpace) + assertEquals(ColorSpaces.DisplayP3, AmberDeep.colorSpace) + assertEquals(ColorSpaces.DisplayP3, OnAmber.colorSpace) + } + + @Test + fun `dark scheme colors are in DisplayP3`() { + listOf(Obsidian, ObsidianElevated, ObsidianInput, DarkText, DarkMuted, DarkStroke) + .forEach { color -> + assertEquals( + "Color $color should be DisplayP3", + ColorSpaces.DisplayP3, + color.colorSpace, + ) + } + } + + @Test + fun `light scheme colors are in DisplayP3`() { + listOf(LightBackground, LightSurface, LightSurfaceVariant, LightText, LightMuted, LightStroke) + .forEach { color -> + assertEquals( + "Color $color should be DisplayP3", + ColorSpaces.DisplayP3, + color.colorSpace, + ) + } + } + + @Test + fun `blob colors are in DisplayP3`() { + listOf(BlobAmber, BlobCoral, BlobHoney, BlobTeal).forEach { color -> + assertEquals(ColorSpaces.DisplayP3, color.colorSpace) + } + } + + @Test + fun `amber has expected approximate values`() { + assertTrue("Red should be around 0.878", Amber.red in 0.87f..0.89f) + assertTrue("Green should be around 0.635", Amber.green in 0.62f..0.65f) + assertTrue("Blue should be around 0.243", Amber.blue in 0.23f..0.26f) + assertEquals(1f, Amber.alpha, 0.001f) + } + + @Test + fun `stroke colors have transparency`() { + assertTrue("DarkStroke should be transparent", DarkStroke.alpha < 1f) + assertTrue("LightStroke should be transparent", LightStroke.alpha < 1f) + } + + @Test + fun `no pure black or pure white in neutrals`() { + assertTrue("Obsidian should not be pure black", Obsidian.red > 0f || Obsidian.green > 0f || Obsidian.blue > 0f) + assertTrue("LightBackground should not be pure white", LightBackground.red < 1f || LightBackground.green < 1f) + } +} diff --git a/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt b/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt index b26a839..4a63b2c 100644 --- a/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt +++ b/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt @@ -1,67 +1,56 @@ package dev.kaizen.app import dev.kaizen.app.settings.AppAppearance -import dev.kaizen.app.settings.KaizenThemeId import dev.kaizen.app.settings.SettingsViewModel +import dev.kaizen.app.ui.theme.themes.KaizenThemeId import org.junit.Assert.assertEquals import org.junit.Test -/** - * High-fidelity JVM Unit Test for the SettingsViewModel. - * - * Verifies that state transitions, user inputs, and theme selections are - * correctly and reactively managed inside the view model under clean MVVM. - */ class SettingsViewModelTest { @Test fun testInitialState() { val viewModel = SettingsViewModel() - - // Assert correct default states - assertEquals(KaizenThemeId.Amber, viewModel.selectedTheme.value) - assertEquals(AppAppearance.Dark, viewModel.appearanceMode.value) - assertEquals("Bruno", viewModel.userName.value) - assertEquals("gemini-2.5-flash", viewModel.defaultModel.value) + assertEquals(KaizenThemeId.AMBER, viewModel.selectedTheme.value) + assertEquals(AppAppearance.System, viewModel.appearanceMode.value) + assertEquals("", viewModel.userName.value) } @Test fun testSelectTheme() { val viewModel = SettingsViewModel() - - // Transition theme to Aurora - viewModel.selectTheme(KaizenThemeId.Aurora) - - assertEquals(KaizenThemeId.Aurora, viewModel.selectedTheme.value) + viewModel.selectTheme(KaizenThemeId.AURORA) + assertEquals(KaizenThemeId.AURORA, viewModel.selectedTheme.value) + } + + @Test + fun testSelectAllThemes() { + val viewModel = SettingsViewModel() + KaizenThemeId.entries.forEach { id -> + viewModel.selectTheme(id) + assertEquals(id, viewModel.selectedTheme.value) + } } @Test fun testSelectAppearance() { val viewModel = SettingsViewModel() - - // Switch appearance to Light Mode viewModel.selectAppearance(AppAppearance.Light) - assertEquals(AppAppearance.Light, viewModel.appearanceMode.value) } @Test fun testUpdateUserName() { val viewModel = SettingsViewModel() - - // Update user's name - viewModel.updateUserName(" Giuseppe ") // Should be trimmed - + viewModel.updateUserName(" Giuseppe ") assertEquals("Giuseppe", viewModel.userName.value) } @Test fun testUpdateUserNameBlankIgnored() { val viewModel = SettingsViewModel() - - // Attempting to set an empty/blank name should be ignored + viewModel.updateUserName("Bruno") viewModel.updateUserName(" ") - - assertEquals("Bruno", viewModel.userName.value) // Remains unchanged + assertEquals("Bruno", viewModel.userName.value) } } diff --git a/app/src/test/java/dev/kaizen/app/ShadowStackTest.kt b/app/src/test/java/dev/kaizen/app/ShadowStackTest.kt new file mode 100644 index 0000000..45e7ceb --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/ShadowStackTest.kt @@ -0,0 +1,70 @@ +package dev.kaizen.app + +import dev.kaizen.app.ui.effect.KaizenShadows +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShadowStackTest { + + @Test + fun `level0 has no layers`() { + assertTrue(KaizenShadows.level0.layers.isEmpty()) + } + + @Test + fun `level1 has 2 layers`() { + assertEquals(2, KaizenShadows.level1.layers.size) + } + + @Test + fun `level2 has 3 layers`() { + assertEquals(3, KaizenShadows.level2.layers.size) + } + + @Test + fun `level3 has 4 layers`() { + assertEquals(4, KaizenShadows.level3.layers.size) + } + + @Test + fun `level4 has 4 layers`() { + assertEquals(4, KaizenShadows.level4.layers.size) + } + + @Test + fun `all alphas are in valid range`() { + listOf( + KaizenShadows.level1, + KaizenShadows.level2, + KaizenShadows.level3, + KaizenShadows.level4, + ).forEach { stack -> + stack.layers.forEach { layer -> + assertTrue("Alpha ${layer.alpha} should be in 0..1", layer.alpha in 0f..1f) + } + } + } + + @Test + fun `blur increases with each layer in level3`() { + val blurs = KaizenShadows.level3.layers.map { it.blur } + for (i in 1 until blurs.size) { + assertTrue( + "Blur should increase: ${blurs[i - 1]} < ${blurs[i]}", + blurs[i] > blurs[i - 1], + ) + } + } + + @Test + fun `alpha decreases with each layer in level3`() { + val alphas = KaizenShadows.level3.layers.map { it.alpha } + for (i in 1 until alphas.size) { + assertTrue( + "Alpha should decrease: ${alphas[i - 1]} > ${alphas[i]}", + alphas[i] < alphas[i - 1], + ) + } + } +} diff --git a/app/src/test/java/dev/kaizen/app/SquircleShapeTest.kt b/app/src/test/java/dev/kaizen/app/SquircleShapeTest.kt new file mode 100644 index 0000000..10f8bcf --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/SquircleShapeTest.kt @@ -0,0 +1,48 @@ +package dev.kaizen.app + +import dev.kaizen.app.ui.shape.KaizenShapes +import dev.kaizen.app.ui.shape.SquircleShape +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SquircleShapeTest { + + @Test + fun `KaizenShapes has all expected tokens`() { + assertNotNull(KaizenShapes.xs) + assertNotNull(KaizenShapes.sm) + assertNotNull(KaizenShapes.md) + assertNotNull(KaizenShapes.lg) + assertNotNull(KaizenShapes.xl) + assertNotNull(KaizenShapes.pill) + assertNotNull(KaizenShapes.circle) + } + + @Test + fun `KaizenShapes tokens are distinct instances`() { + val shapes = listOf( + KaizenShapes.xs, KaizenShapes.sm, KaizenShapes.md, + KaizenShapes.lg, KaizenShapes.xl, KaizenShapes.pill, + ) + val unique = shapes.toSet() + assertEquals("All squircle shape tokens should be distinct", shapes.size, unique.size) + } + + @Test + fun `SquircleShape toString contains parameters`() { + val shape = SquircleShape(radius = 16.dp, smoothness = 4.5f) + val str = shape.toString() + assertTrue("Should contain radius", str.contains("16")) + assertTrue("Should contain smoothness", str.contains("4.5")) + } + + @Test + fun `SquircleShape with different smoothness creates different instances`() { + val a = SquircleShape(radius = 16.dp, smoothness = 4.0f) + val b = SquircleShape(radius = 16.dp, smoothness = 5.0f) + assertTrue("Different smoothness should produce different toString", a.toString() != b.toString()) + } +} diff --git a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt index 0de9144..6ab42ba 100644 --- a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt +++ b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt @@ -2,21 +2,16 @@ package dev.kaizen.app import dev.kaizen.app.net.StreamConsumer import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test -/** - * Characterization tests for the sentinel-stripping stream parser — mirrors the - * backend's lib/chat/stream-consumer.ts so the app shows exactly the same visible - * text the web does. Tests both [StreamConsumer.visibleText] (batch) and - * [StreamConsumer.Incremental] (streaming). - */ class StreamConsumerTest { - private val usage = 0.toChar() // - private val reasoning = 1.toChar() // - private val sources = 2.toChar() // - private val tool = 3.toChar() // - private val query = 4.toChar() // + private val usage = 0.toChar() + private val reasoning = 1.toChar() + private val sources = 2.toChar() + private val tool = 3.toChar() + private val query = 4.toChar() // ── Batch parser (visibleText) ────────────────────────────────────────── @@ -27,38 +22,27 @@ class StreamConsumerTest { @Test fun usageSentinel_isClipped() { - val raw = "Antwort.$usage{\"i\":12,\"o\":34}" - assertEquals("Antwort.", StreamConsumer.visibleText(raw)) + assertEquals("Antwort.", StreamConsumer.visibleText("Antwort.$usage{\"i\":12,\"o\":34}")) } @Test fun reasoning_isStripped_contentKept() { - val raw = "${reasoning}denke nach${reasoning}Die Antwort ist 42." - assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText(raw)) + assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText("${reasoning}denke nach${reasoning}Die Antwort ist 42.")) } @Test fun reasoning_thenContent_thenUsage() { - val raw = "${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}" - assertEquals("Fertig.", StreamConsumer.visibleText(raw)) + assertEquals("Fertig.", StreamConsumer.visibleText("${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}")) } @Test fun searchQueryAndSources_areClipped() { - val raw = "Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}" - assertEquals("Hier das Wetter.", StreamConsumer.visibleText(raw)) + assertEquals("Hier das Wetter.", StreamConsumer.visibleText("Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}")) } @Test fun leadingToolEvents_areStripped() { - val raw = "$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort." - assertEquals("Die Antwort.", StreamConsumer.visibleText(raw)) - } - - @Test - fun incompleteLeadingToolEvent_showsNothing() { - val raw = "$tool{\"type\":\"thi" - assertEquals("", StreamConsumer.visibleText(raw)) + assertEquals("Die Antwort.", StreamConsumer.visibleText("$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort.")) } @Test @@ -66,121 +50,100 @@ class StreamConsumerTest { assertEquals("", StreamConsumer.visibleText("")) } - @Test - fun reasoningOnly_noContentYet_returnsEmpty() { - val raw = "${reasoning}immer noch am denken" - assertEquals("", StreamConsumer.visibleText(raw)) - } - - // ── Incremental parser ───────────────────────────────────────────────── - // Each test feeds the same input in various chunking patterns to verify - // that the incremental result matches the batch result exactly. + // ── Incremental parser — content ─────────────────────────────────────── @Test - fun incremental_plainText_singleChunk() { + fun incremental_plainText() { val inc = StreamConsumer.Incremental() - assertEquals("Hallo Welt", inc.append("Hallo Welt")) + assertEquals("Hallo Welt", inc.append("Hallo Welt").content) } @Test fun incremental_plainText_charByChar() { val inc = StreamConsumer.Incremental() - val input = "Hallo Welt" - var result = "" - for (c in input) result = inc.append(c.toString()) - assertEquals("Hallo Welt", result) + var state = inc.append("") + for (c in "Hallo Welt") state = inc.append(c.toString()) + assertEquals("Hallo Welt", state.content) } @Test fun incremental_usageSentinel_isClipped() { val inc = StreamConsumer.Incremental() - assertEquals("Antwort.", inc.append("Antwort.")) - assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}")) + inc.append("Antwort.") + assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}").content) } @Test - fun incremental_reasoning_isStripped() { + fun incremental_reasoning_captured() { val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("${reasoning}denke")) - assertEquals("", inc.append(" nach")) - assertEquals("Die ", inc.append("${reasoning}Die ")) - assertEquals("Die Antwort ist 42.", inc.append("Antwort ist 42.")) + inc.append("${reasoning}denke nach") + val state = inc.append("${reasoning}Die Antwort.") + assertEquals("Die Antwort.", state.content) + assertEquals("denke nach", state.reasoning) } @Test fun incremental_reasoning_thenContent_thenUsage() { val inc = StreamConsumer.Incremental() inc.append("${reasoning}grübel${reasoning}") - val result = inc.append("Fertig.$usage{\"i\":1,\"o\":2}") - assertEquals("Fertig.", result) + val state = inc.append("Fertig.$usage{\"i\":1,\"o\":2}") + assertEquals("Fertig.", state.content) + assertEquals("grübel", state.reasoning) } @Test - fun incremental_searchQueryAndSources_areClipped() { + fun incremental_sources_parsed() { val inc = StreamConsumer.Incremental() - inc.append("Hier das Wetter.") - val result = inc.append("${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}") - assertEquals("Hier das Wetter.", result) + val state = inc.append("Wetter.${query}\"berlin wetter\"${sources}[{\"title\":\"t\",\"url\":\"u\",\"snippet\":\"s\"}]$usage{}") + assertEquals("Wetter.", state.content) + assertEquals("\"berlin wetter\"", state.query) + assertEquals(1, state.sources.size) + assertEquals("t", state.sources[0].title) + assertEquals("u", state.sources[0].url) } @Test - fun incremental_toolEvents_singleChunk() { + fun incremental_toolEvents_parsed() { val inc = StreamConsumer.Incremental() - val raw = "$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort." - assertEquals("Die Antwort.", inc.append(raw)) + val state = inc.append("$tool{\"type\":\"start\",\"name\":\"web_search\"}\n${tool}{\"type\":\"end\",\"name\":\"web_search\",\"elapsed\":120}\nAntwort.") + assertEquals("Antwort.", state.content) + assertEquals(2, state.tools.size) + assertEquals("start", state.tools[0].type) + assertEquals("web_search", state.tools[0].name) + assertEquals("end", state.tools[1].type) + assertEquals(120L, state.tools[1].elapsed) } @Test fun incremental_toolEvents_splitAcrossChunks() { val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("$tool{\"type\":\"thin")) - assertEquals("", inc.append("king\"}\n$tool{\"type\":\"sta")) - assertEquals("Die Antwort.", inc.append("rt\"}\nDie Antwort.")) - } - - @Test - fun incremental_incompleteToolEvent_showsNothing() { - val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("$tool{\"type\":\"thi")) - } - - @Test - fun incremental_emptyAppend_returnsEmpty() { - val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("")) - } - - @Test - fun incremental_reasoningOnly_returnsEmpty() { - val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("${reasoning}immer noch am denken")) + inc.append("$tool{\"type\":\"sta") + inc.append("rt\",\"name\":\"x\"}\n") + val state = inc.append("Content.") + assertEquals("Content.", state.content) + assertEquals(1, state.tools.size) } @Test fun incremental_afterDone_ignoresMore() { val inc = StreamConsumer.Incremental() inc.append("Fertig.") - val afterUsage = inc.append("${usage}{\"i\":1}") - assertEquals("Fertig.", afterUsage) - assertEquals("Fertig.", inc.append("ignored trailing garbage")) + inc.append("${usage}{\"i\":1}") + assertEquals("Fertig.", inc.append("ignored").content) } @Test - fun incremental_toolEvents_thenReasoning_thenContent() { + fun incremental_fullStream_allSections() { + val full = "$tool{\"type\":\"start\",\"name\":\"t\"}\n${reasoning}grübel${reasoning}Die Antwort.${query}\"q\"${sources}[{\"title\":\"x\",\"url\":\"y\",\"snippet\":\"z\"}]$usage{}" val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("$tool{\"type\":\"thinking\"}\n")) - assertEquals("", inc.append("${reasoning}deep thought")) - assertEquals("42", inc.append("${reasoning}42")) - } - - @Test - fun incremental_matchesBatch_fullStream() { - val full = "$tool{\"type\":\"thinking\"}\n${reasoning}grübel${reasoning}Die Antwort.${query}q${sources}s$usage{}" - val expected = StreamConsumer.visibleText(full) - - val inc = StreamConsumer.Incremental() - var result = "" - for (c in full) result = inc.append(c.toString()) - assertEquals(expected, result) + var state = inc.append("") + for (c in full) state = inc.append(c.toString()) + assertEquals("Die Antwort.", state.content) + assertEquals("grübel", state.reasoning) + assertEquals(1, state.tools.size) + assertEquals("t", state.tools[0].name) + assertEquals("\"q\"", state.query) + assertEquals(1, state.sources.size) + assertEquals("x", state.sources[0].title) } } diff --git a/app/src/test/java/dev/kaizen/app/ThemeRegistryTest.kt b/app/src/test/java/dev/kaizen/app/ThemeRegistryTest.kt new file mode 100644 index 0000000..83a0adb --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/ThemeRegistryTest.kt @@ -0,0 +1,62 @@ +package dev.kaizen.app + +import dev.kaizen.app.ui.theme.themes.KaizenThemeId +import dev.kaizen.app.ui.theme.themes.ThemeRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ThemeRegistryTest { + + @Test + fun `registry contains all four themes`() { + assertEquals(4, ThemeRegistry.all.size) + val ids = ThemeRegistry.all.map { it.id }.toSet() + assertTrue(ids.contains(KaizenThemeId.AMBER)) + assertTrue(ids.contains(KaizenThemeId.BLAU)) + assertTrue(ids.contains(KaizenThemeId.MONO)) + assertTrue(ids.contains(KaizenThemeId.AURORA)) + } + + @Test + fun `forId returns correct theme`() { + KaizenThemeId.entries.forEach { id -> + val theme = ThemeRegistry.forId(id) + assertEquals(id, theme.id) + } + } + + @Test + fun `all themes have non-empty labels`() { + ThemeRegistry.all.forEach { theme -> + assertTrue("Theme ${theme.id} should have a label", theme.label.isNotBlank()) + } + } + + @Test + fun `all themes have distinct blob colors per scheme`() { + ThemeRegistry.all.forEach { theme -> + listOf(theme.dark, theme.light).forEach { scheme -> + val blobs = setOf(scheme.blob1, scheme.blob2, scheme.blob3, scheme.blob4) + assertTrue( + "Theme ${theme.id} should have at least 3 distinct blob colors", + blobs.size >= 3, + ) + } + } + } + + @Test + fun `KaizenThemeId fromString parses all ids`() { + assertEquals(KaizenThemeId.AMBER, KaizenThemeId.fromString("amber")) + assertEquals(KaizenThemeId.BLAU, KaizenThemeId.fromString("blue")) + assertEquals(KaizenThemeId.MONO, KaizenThemeId.fromString("mono")) + assertEquals(KaizenThemeId.AURORA, KaizenThemeId.fromString("aurora")) + } + + @Test + fun `KaizenThemeId fromString falls back to AMBER for unknown`() { + assertEquals(KaizenThemeId.AMBER, KaizenThemeId.fromString("unknown")) + assertEquals(KaizenThemeId.AMBER, KaizenThemeId.fromString("")) + } +}