merge: design system v2 — complete UX/UI overhaul
12 commits: P3 color pipeline, squircle shapes, GlassSurface, multi-layer
shadows, 4-theme system (Amber/Blau/Mono/Aurora from web CSS), Inter Variable
font, sensor tilt, i18n (de+en), app icon, persistent settings, server sync,
reasoning/sources/tools display, Oklab precision fix.
Rollback: git reset --hard 203316b
302
CLAUDE.md
Normal file
|
|
@ -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 <token>` 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<String>` 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<ToolEvent>` (type, name, args, result, error, elapsed)
|
||||
- `query` — search query string
|
||||
- `sources` — `List<SearchSource>` (title, url, snippet)
|
||||
|
||||
**KaizenApi.chat()** returns `Flow<StreamState>`. 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<Attachment>` ↔ 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<T>` 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<Attachment>` ↔ 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.
|
||||
970
DESIGN.md
Normal file
|
|
@ -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/<id>.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: "<color>"; 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_<theme>_<mode>.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<Float>`, 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<ShadowLayer>)
|
||||
|
||||
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<Offset> {
|
||||
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<Float>(dampingRatio = 0.55f, stiffness = 800f)
|
||||
val SpringDefault = spring<Float>(dampingRatio = 0.75f, stiffness = 400f)
|
||||
val SpringGentle = spring<Float>(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_<theme>_<mode>.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. |
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,58 +96,23 @@ 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),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
shape = KaizenShapes.lg,
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 24.dp,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
|
|
@ -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,106 +290,49 @@ 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,
|
||||
) {
|
||||
Column {
|
||||
if (pendingFiles.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
|
||||
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) })
|
||||
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(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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
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,
|
||||
value = value, onValueChange = onValueChange,
|
||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
|
||||
cursorBrush = SolidColor(Amber),
|
||||
maxLines = 6,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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 }
|
||||
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
|
||||
SendButton(enabled = canSend, onClick = onSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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<String, ImageBitmap>(32, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = 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<ImageBitmap?>(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<Attachment>, 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)
|
||||
|
|
|
|||
|
|
@ -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<Attachment> = emptyList(),
|
||||
val reasoning: String = "",
|
||||
val tools: List<ToolEvent> = emptyList(),
|
||||
val query: String = "",
|
||||
val sources: List<SearchSource> = 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,23 +434,24 @@ 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() } },
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Menu,
|
||||
contentDescription = "Menü öffnen",
|
||||
contentDescription = context.getString(R.string.chat_open_menu),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
ModelPill(
|
||||
label = modelDisplayName(session.config?.model ?: "", models),
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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<ConversationSummary>): Map<String, List<ConversationSummary>> {
|
||||
private fun groupConversations(conversations: List<ConversationSummary>, labels: DateLabels): Map<String, List<ConversationSummary>> {
|
||||
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
|
||||
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))
|
||||
.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()
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
|
||||
// --- HEADER Sektion: Brand title and New Chat button ---
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
215
app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt
Normal file
|
|
@ -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<ToolEvent>, 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<SearchSource>, 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MeResponse>(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<WireMessage>,
|
||||
speed: ChatSpeed = ChatSpeed.Normal,
|
||||
attachments: List<Attachment>? = null,
|
||||
): Flow<String> = flow {
|
||||
): Flow<StreamState> = flow {
|
||||
val payload = json.encodeToString(
|
||||
ChatRequest(
|
||||
model = model,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolEvent> = emptyList(),
|
||||
val query: String = "",
|
||||
val sources: List<SearchSource> = 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] <content> [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<ToolEvent>()
|
||||
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<SearchSource> {
|
||||
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<List<SearchSource>>(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<ToolEvent>(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<KaizenThemeId> = _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<AppAppearance> = _appearanceMode.asStateFlow()
|
||||
|
||||
// 3. User profile details
|
||||
private val _userName = MutableStateFlow("Bruno")
|
||||
private val _userName = MutableStateFlow(store?.loadUserName() ?: "")
|
||||
val userName: StateFlow<String> = _userName.asStateFlow()
|
||||
|
||||
private val _userEmail = MutableStateFlow("admin@kryptomrx.de")
|
||||
private val _userEmail = MutableStateFlow("")
|
||||
val userEmail: StateFlow<String> = _userEmail.asStateFlow()
|
||||
|
||||
// 4. Default Chat Model
|
||||
private val _defaultModel = MutableStateFlow("gemini-2.5-flash")
|
||||
val defaultModel: StateFlow<String> = _defaultModel.asStateFlow()
|
||||
private val _locale = MutableStateFlow(store?.loadLocale() ?: "system")
|
||||
val locale: StateFlow<String> = _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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt
Normal file
|
|
@ -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
|
||||
}
|
||||
148
app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt
Normal file
|
|
@ -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<ShadowLayer>)
|
||||
|
||||
/**
|
||||
* 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<ShadowLayer>, 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
52
app/src/main/java/dev/kaizen/app/ui/motion/Motion.kt
Normal file
|
|
@ -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<Float> = spring(dampingRatio = 0.55f, stiffness = 800f)
|
||||
|
||||
/** Balanced — general state changes */
|
||||
val SpringDefault: SpringSpec<Float> = spring(dampingRatio = 0.75f, stiffness = 400f)
|
||||
|
||||
/** Slow, fluid — tilt response, sidebar slide */
|
||||
val SpringGentle: SpringSpec<Float> = 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
|
||||
}
|
||||
145
app/src/main/java/dev/kaizen/app/ui/sensor/TiltState.kt
Normal file
|
|
@ -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<Offset> {
|
||||
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
|
||||
}
|
||||
21
app/src/main/java/dev/kaizen/app/ui/shape/KaizenShapes.kt
Normal file
|
|
@ -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
|
||||
}
|
||||
126
app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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)
|
||||
}
|
||||
|
||||
return Lab(interpolatedL, interpolatedA, interpolatedB).toColor(targetSpace).copy(alpha = interpolatedAlpha)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Color> {
|
||||
val list = ArrayList<Color>(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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
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 = if (darkTheme) KaizenDark else KaizenLight,
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
*/
|
||||
letterSpacing = 0.33.sp,
|
||||
),
|
||||
)
|
||||
|
|
@ -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/<id>.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,
|
||||
)
|
||||
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
@ -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<KaizenThemeSpec> = listOf(
|
||||
AmberTheme,
|
||||
BlauTheme,
|
||||
MonoTheme,
|
||||
AuroraTheme,
|
||||
)
|
||||
|
||||
fun forId(id: KaizenThemeId): KaizenThemeSpec =
|
||||
all.firstOrNull { it.id == id } ?: AmberTheme
|
||||
}
|
||||
21
app/src/main/res/drawable-night/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Dark mode: Obsidian with subtle blue steel gradient -->
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="54"
|
||||
android:centerY="54"
|
||||
android:gradientRadius="76">
|
||||
<item android:color="#FF111824" android:offset="0.0" />
|
||||
<item android:color="#FF0A0E16" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
|
@ -1,170 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<!-- Light mode: warm off-white with subtle amber tint -->
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="54"
|
||||
android:centerY="54"
|
||||
android:gradientRadius="76">
|
||||
<item android:color="#FFF8F4EE" android:offset="0.0" />
|
||||
<item android:color="#FFF1EBE3" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,91 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
|
||||
<!-- Soft ambient glow behind the orb -->
|
||||
<path android:pathData="M54,54m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
android:type="radial"
|
||||
android:centerX="54"
|
||||
android:centerY="54"
|
||||
android:gradientRadius="30">
|
||||
<item android:color="#50E0A23E" android:offset="0.0" />
|
||||
<item android:color="#18E0A23E" android:offset="0.6" />
|
||||
<item android:color="#00000000" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Orb body — rich amber radial gradient, light source top-left -->
|
||||
<path android:pathData="M54,54m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="47"
|
||||
android:centerY="45"
|
||||
android:gradientRadius="28">
|
||||
<item android:color="#FFFFFBE6" android:offset="0.0" />
|
||||
<item android:color="#FFFFD685" android:offset="0.25" />
|
||||
<item android:color="#FFE0A23E" android:offset="0.50" />
|
||||
<item android:color="#FFC8862A" android:offset="0.75" />
|
||||
<item android:color="#FF7A4400" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Inner volume shadow — heavy darkening at bottom edge for 3D depth -->
|
||||
<path android:pathData="M54,54m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="50"
|
||||
android:centerY="48"
|
||||
android:gradientRadius="22">
|
||||
<item android:color="#00000000" android:offset="0.0" />
|
||||
<item android:color="#00000000" android:offset="0.50" />
|
||||
<item android:color="#993D1E00" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Primary specular highlight — top-left, bright white gloss -->
|
||||
<path android:pathData="M46,44m-9,0a9,9 0,1 1,18 0a9,9 0,1 1,-18 0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="46"
|
||||
android:centerY="44"
|
||||
android:gradientRadius="9">
|
||||
<item android:color="#E0FFFFFF" android:offset="0.0" />
|
||||
<item android:color="#40FFFFFF" android:offset="0.4" />
|
||||
<item android:color="#00FFFFFF" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Secondary warm reflection — bottom-right bounce light -->
|
||||
<path android:pathData="M63,63m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="63"
|
||||
android:centerY="63"
|
||||
android:gradientRadius="7">
|
||||
<item android:color="#80FFD080" android:offset="0.0" />
|
||||
<item android:color="#00FFD080" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Crisp outer rim — thin glass edge -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
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" />
|
||||
</vector>
|
||||
36
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Simple orb circle for themed icons -->
|
||||
<path android:pathData="M54,54m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="48"
|
||||
android:centerY="46"
|
||||
android:gradientRadius="24">
|
||||
<item android:color="#FFFFFFFF" android:offset="0.0" />
|
||||
<item android:color="#80FFFFFF" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Specular dot -->
|
||||
<path android:pathData="M54,54m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="45"
|
||||
android:centerY="43"
|
||||
android:gradientRadius="8">
|
||||
<item android:color="#FFFFFFFF" android:offset="0.0" />
|
||||
<item android:color="#00FFFFFF" android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
BIN
app/src/main/res/font/inter_variable.ttf
Normal file
|
|
@ -2,5 +2,5 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -2,5 +2,5 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
103
app/src/main/res/values-en/strings.xml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<resources>
|
||||
<!-- Greeting -->
|
||||
<string name="greeting_morning">Good Morning</string>
|
||||
<string name="greeting_day">Good Afternoon</string>
|
||||
<string name="greeting_evening">Good Evening</string>
|
||||
<string name="greeting_night">Hello</string>
|
||||
<string name="hero_headline">What\'s up?</string>
|
||||
|
||||
<!-- Chat -->
|
||||
<string name="chat_input_placeholder">Type a message …</string>
|
||||
<string name="chat_no_conversations">No chats yet</string>
|
||||
<string name="chat_locked">Locked Chat</string>
|
||||
<string name="chat_locked_badge">Locked</string>
|
||||
<string name="chat_new">New Chat</string>
|
||||
<string name="chat_search">Search…</string>
|
||||
<string name="chat_attachment">Attachment</string>
|
||||
<string name="chat_call">Call</string>
|
||||
<string name="chat_remove">Remove</string>
|
||||
<string name="chat_error">Error</string>
|
||||
<string name="chat_open_menu">Open menu</string>
|
||||
|
||||
<!-- Stream blocks -->
|
||||
<string name="stream_reasoning">Reasoning</string>
|
||||
<string name="stream_sources">Sources</string>
|
||||
|
||||
<!-- Chat errors -->
|
||||
<string name="error_session_expired">Session expired. Please sign in again.</string>
|
||||
<string name="error_credits_exhausted">Credits exhausted.</string>
|
||||
<string name="error_rate_limited">Too many requests. Please wait.</string>
|
||||
<string name="error_server">Server error (%1$d).</string>
|
||||
<string name="error_no_connection">No connection to server.</string>
|
||||
|
||||
<!-- Mode pills -->
|
||||
<string name="mode_standard">Standard</string>
|
||||
<string name="mode_sampling">Sampling</string>
|
||||
<string name="mode_image">Image</string>
|
||||
<string name="mode_search">Search</string>
|
||||
<string name="mode_video">Video</string>
|
||||
<string name="mode_audio">Audio</string>
|
||||
|
||||
<!-- Suggestion pills -->
|
||||
<string name="suggest_brainstorm">Brainstorm</string>
|
||||
<string name="suggest_brainstorm_prompt">Let\'s brainstorm on a topic.</string>
|
||||
<string name="suggest_image">Generate Image</string>
|
||||
<string name="suggest_image_prompt">Generate an image of a mountain lake at sunrise.</string>
|
||||
<string name="suggest_video">Create Video</string>
|
||||
<string name="suggest_video_prompt">Create a short video of a city at night.</string>
|
||||
<string name="suggest_web">Search the Web</string>
|
||||
<string name="suggest_web_prompt">Search the web for the latest AI news.</string>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<string name="sidebar_brand">kaizen</string>
|
||||
<string name="sidebar_pinned">★ PINNED</string>
|
||||
<string name="sidebar_today">TODAY</string>
|
||||
<string name="sidebar_yesterday">YESTERDAY</string>
|
||||
<string name="sidebar_earlier">EARLIER</string>
|
||||
<string name="sidebar_logout">Sign Out</string>
|
||||
<string name="sidebar_settings">Settings</string>
|
||||
|
||||
<!-- Model sheet -->
|
||||
<string name="model_sheet_title">Select Model</string>
|
||||
<string name="model_search_placeholder">Search models…</string>
|
||||
<string name="model_search_clear">Clear</string>
|
||||
<string name="model_connecting">Connecting…</string>
|
||||
<string name="model_no_results">No matches</string>
|
||||
<string name="model_favorite">Favorite</string>
|
||||
<string name="model_selected">Selected</string>
|
||||
<string name="model_favorites_group">★ Favorites</string>
|
||||
|
||||
<!-- Login -->
|
||||
<string name="login_welcome">Welcome</string>
|
||||
<string name="login_subtitle">Sign in to your Kaizen instance</string>
|
||||
<string name="login_url_placeholder">https://your-instance.com</string>
|
||||
<string name="login_email_placeholder">Email</string>
|
||||
<string name="login_password_placeholder">Password</string>
|
||||
<string name="login_submit">Sign In</string>
|
||||
<string name="login_error_credentials">Invalid email or password.</string>
|
||||
<string name="login_error_rate_limited">Too many attempts. Please wait.</string>
|
||||
<string name="login_error_connection">Connection failed. Check server address.</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_back">Back</string>
|
||||
<string name="settings_section_appearance">APPEARANCE</string>
|
||||
<string name="settings_section_profile">PROFILE & ACCOUNT</string>
|
||||
<string name="settings_section_ai">AI PROFILE & CONTEXT</string>
|
||||
<string name="settings_accent_title">Accent Color</string>
|
||||
<string name="settings_accent_desc">Choose the app color scheme</string>
|
||||
<string name="settings_appearance_title">Appearance</string>
|
||||
<string name="settings_appearance_desc">Set light/dark appearance</string>
|
||||
<string name="settings_name_title">Display Name</string>
|
||||
<string name="settings_name_desc">Change your display name</string>
|
||||
<string name="settings_email_title">Email Address</string>
|
||||
<string name="settings_ai_title">System Prompt & Facts</string>
|
||||
<string name="settings_appearance_light">Light</string>
|
||||
<string name="settings_appearance_dark">Dark</string>
|
||||
<string name="settings_appearance_system">System</string>
|
||||
<string name="settings_language_title">Language</string>
|
||||
<string name="settings_language_desc">Choose the app language</string>
|
||||
<string name="settings_language_system">System</string>
|
||||
<string name="settings_language_de">Deutsch</string>
|
||||
<string name="settings_language_en">English</string>
|
||||
</resources>
|
||||
|
|
@ -1,3 +1,105 @@
|
|||
<resources>
|
||||
<string name="app_name">Kaizen</string>
|
||||
|
||||
<!-- Greeting -->
|
||||
<string name="greeting_morning">Guten Morgen</string>
|
||||
<string name="greeting_day">Guten Tag</string>
|
||||
<string name="greeting_evening">Guten Abend</string>
|
||||
<string name="greeting_night">Hallo</string>
|
||||
<string name="hero_headline">Was liegt an?</string>
|
||||
|
||||
<!-- Chat -->
|
||||
<string name="chat_input_placeholder">Nachricht eingeben …</string>
|
||||
<string name="chat_no_conversations">Noch keine Chats</string>
|
||||
<string name="chat_locked">Gesperrter Chat</string>
|
||||
<string name="chat_locked_badge">Gesperrt</string>
|
||||
<string name="chat_new">Neuer Chat</string>
|
||||
<string name="chat_search">Suchen…</string>
|
||||
<string name="chat_attachment">Anhang</string>
|
||||
<string name="chat_call">Anruf</string>
|
||||
<string name="chat_remove">Entfernen</string>
|
||||
<string name="chat_error">Fehler</string>
|
||||
<string name="chat_open_menu">Menü öffnen</string>
|
||||
|
||||
<!-- Stream blocks -->
|
||||
<string name="stream_reasoning">Gedankengang</string>
|
||||
<string name="stream_sources">Quellen</string>
|
||||
|
||||
<!-- Chat errors -->
|
||||
<string name="error_session_expired">Sitzung abgelaufen. Bitte erneut anmelden.</string>
|
||||
<string name="error_credits_exhausted">Guthaben aufgebraucht.</string>
|
||||
<string name="error_rate_limited">Zu viele Anfragen. Kurz warten.</string>
|
||||
<string name="error_server">Fehler vom Server (%1$d).</string>
|
||||
<string name="error_no_connection">Keine Verbindung zum Server.</string>
|
||||
|
||||
<!-- Mode pills -->
|
||||
<string name="mode_standard">Standard</string>
|
||||
<string name="mode_sampling">Sampling</string>
|
||||
<string name="mode_image">Bild</string>
|
||||
<string name="mode_search">Suche</string>
|
||||
<string name="mode_video">Video</string>
|
||||
<string name="mode_audio">Audio</string>
|
||||
|
||||
<!-- Suggestion pills -->
|
||||
<string name="suggest_brainstorm">Brainstormen</string>
|
||||
<string name="suggest_brainstorm_prompt">Lass uns zu einem Thema brainstormen.</string>
|
||||
<string name="suggest_image">Bild generieren</string>
|
||||
<string name="suggest_image_prompt">Generiere ein Bild von einem Bergsee bei Sonnenaufgang.</string>
|
||||
<string name="suggest_video">Video erstellen</string>
|
||||
<string name="suggest_video_prompt">Erstelle ein kurzes Video von einer Stadt bei Nacht.</string>
|
||||
<string name="suggest_web">Web durchsuchen</string>
|
||||
<string name="suggest_web_prompt">Durchsuche das Web nach den neuesten KI-Nachrichten.</string>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<string name="sidebar_brand">kaizen</string>
|
||||
<string name="sidebar_pinned">★ ANGEHEFTET</string>
|
||||
<string name="sidebar_today">HEUTE</string>
|
||||
<string name="sidebar_yesterday">GESTERN</string>
|
||||
<string name="sidebar_earlier">FRÜHER</string>
|
||||
<string name="sidebar_logout">Abmelden</string>
|
||||
<string name="sidebar_settings">Einstellungen</string>
|
||||
|
||||
<!-- Model sheet -->
|
||||
<string name="model_sheet_title">Modell auswählen</string>
|
||||
<string name="model_search_placeholder">Modell suchen…</string>
|
||||
<string name="model_search_clear">Löschen</string>
|
||||
<string name="model_connecting">Verbindung wird hergestellt…</string>
|
||||
<string name="model_no_results">Keine Treffer</string>
|
||||
<string name="model_favorite">Favorit</string>
|
||||
<string name="model_selected">Ausgewählt</string>
|
||||
<string name="model_favorites_group">★ Favoriten</string>
|
||||
|
||||
<!-- Login -->
|
||||
<string name="login_welcome">Willkommen</string>
|
||||
<string name="login_subtitle">Melde dich bei deiner Kaizen-Instanz an</string>
|
||||
<string name="login_url_placeholder">https://deine-instanz.de</string>
|
||||
<string name="login_email_placeholder">E-Mail</string>
|
||||
<string name="login_password_placeholder">Passwort</string>
|
||||
<string name="login_submit">Anmelden</string>
|
||||
<string name="login_error_credentials">E-Mail oder Passwort ist falsch.</string>
|
||||
<string name="login_error_rate_limited">Zu viele Versuche. Bitte kurz warten.</string>
|
||||
<string name="login_error_connection">Verbindung fehlgeschlagen. Server-Adresse prüfen.</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_back">Zurück</string>
|
||||
<string name="settings_section_appearance">ERSCHEINUNGSBILD</string>
|
||||
<string name="settings_section_profile">PROFIL & KONTO</string>
|
||||
<string name="settings_section_ai">KI-PROFIL & KONTEXT</string>
|
||||
<string name="settings_accent_title">Akzentfarbe</string>
|
||||
<string name="settings_accent_desc">Wähle das Farbschema der App</string>
|
||||
<string name="settings_appearance_title">Helligkeitsmodus</string>
|
||||
<string name="settings_appearance_desc">Passe das Hell/Dunkel-Erscheinungsbild an</string>
|
||||
<string name="settings_name_title">Benutzername</string>
|
||||
<string name="settings_name_desc">Ändere deinen Anzeigenamen</string>
|
||||
<string name="settings_email_title">E-Mail-Adresse</string>
|
||||
<string name="settings_ai_title">System-Prompt & Fakten</string>
|
||||
<string name="settings_appearance_light">Hell</string>
|
||||
<string name="settings_appearance_dark">Dunkel</string>
|
||||
<string name="settings_appearance_system">System</string>
|
||||
<string name="settings_language_title">Sprache</string>
|
||||
<string name="settings_language_desc">Wähle die App-Sprache</string>
|
||||
<string name="settings_language_system">System</string>
|
||||
<string name="settings_language_de">Deutsch</string>
|
||||
<string name="settings_language_en">English</string>
|
||||
</resources>
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Kaizen" parent="android:Theme.Material.NoActionBar">
|
||||
<!-- Adapts to light/dark via values/ + values-night/; avoids a flash before the first Compose frame -->
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<!-- Splash screen (API 31+) -->
|
||||
<item name="android:windowSplashScreenBackground">@color/window_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
86
app/src/test/java/dev/kaizen/app/ColorPipelineTest.kt
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
viewModel.selectTheme(KaizenThemeId.AURORA)
|
||||
assertEquals(KaizenThemeId.AURORA, viewModel.selectedTheme.value)
|
||||
}
|
||||
|
||||
// Transition theme to Aurora
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
app/src/test/java/dev/kaizen/app/ShadowStackTest.kt
Normal file
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/src/test/java/dev/kaizen/app/SquircleShapeTest.kt
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
app/src/test/java/dev/kaizen/app/ThemeRegistryTest.kt
Normal file
|
|
@ -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(""))
|
||||
}
|
||||
}
|
||||