Compare commits

..

16 commits

Author SHA1 Message Date
Bruno Deanoz
320ea855fd 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
2026-06-21 15:39:04 +02:00
Bruno Deanoz
9f2a7f2984 docs: CLAUDE.md — complete update for design system v2 session
Added: sentinel parsing section (reasoning/sources/tools/query),
app icon section, updated TODO checklist (3 items checked off),
removed pre-existing OklabTest failures section (all fixed),
updated test descriptions, removed stale branch reference.
2026-06-21 15:38:12 +02:00
Bruno Deanoz
76ab8bfde9 feat: reasoning, sources, and tool-call display
StreamConsumer.Incremental now returns StreamState (content, reasoning,
tools, query, sources) instead of just visible text. All sentinel sections
are parsed and structured:
- \x01 REASONING: captured as separate string
- \x02 SOURCES: parsed as List<SearchSource> (title, url, snippet)
- \x03 TOOL: parsed as List<ToolEvent> (type, name, args, result, elapsed)
- \x04 QUERY: captured as search query string

UI components:
- ReasoningBlock: collapsible "Gedankengang" with expand/collapse animation
- ToolEventsBlock: shows tool-call steps with status icons (start/end/error)
- SourcesBlock: web search sources as compact cards with domain extraction

Message model extended with reasoning, tools, query, sources fields.
KaizenApi.chat() returns Flow<StreamState> instead of Flow<String>.
ChatScreen wires all fields to Message during streaming.

Fixed: empty append() on Incremental no longer prematurely ends tool phase.
StreamConsumerTest rewritten to verify all parsed sections.
2026-06-21 15:35:46 +02:00
Bruno Deanoz
736bb4c206 feat: persistent settings + server sync + language selector
Settings now persist across app restarts via SecureStore:
- Theme (KaizenThemeId), appearance (Light/Dark/System), username, locale
- All loaded on SettingsViewModel init, saved on every change

Server sync: fetchMe() returns name + email from /api/v1/me,
populates SettingsViewModel on login and each foreground resume.

Language selector: System / Deutsch / English pill toggle in Settings.
Locale preference stored; runtime switching deferred to next iteration.

MeResponse expanded to parse name + email (was defaultModel only).
SettingsViewModel tests updated for store-less default state.
2026-06-21 15:26:14 +02:00
Bruno Deanoz
9e4eaf2297 feat: Kaizen orb app icon — light/dark backgrounds, monochrome, splash
Adaptive icon with amber orb on warm off-white (light) / Obsidian (dark).
Multi-layer radial gradients for the orb: body, depth shadow, specular
highlight, warm bounce light, crisp rim. Monochrome layer for Android 13+
themed icons. Splash screen uses orb foreground (API 31+).

Removed all old raster WebP icons — vector-only via mipmap-anydpi.
2026-06-21 15:22:28 +02:00
Bruno Deanoz
515a71efb5 feat: Inter Variable font, theme picker wired end-to-end, settings i18n
Inter Variable (v4.1, 859 KB): full type scale with 9 weight axes
(W100–W900), negative tracking at display sizes, positive at labels.

Theme picker: SettingsViewModel now uses KaizenThemeId from the design
system (AMBER/BLAU/MONO/AURORA). MainActivity wires selectedTheme +
appearanceMode to KaizenTheme composable — switching themes in Settings
instantly propagates to the entire app.

No server sync needed — web stores theme in localStorage, app stores
locally. Backend /api/v1/me has no uiTheme field in the DB.

Settings strings migrated to string resources (de + en).
SettingsViewModelTest updated for new enum values + System default.
2026-06-21 15:17:17 +02:00
Bruno Deanoz
f83d4b4e17 fix: Oklab round-trip precision — Double math + corrected LMS matrix
Root cause: Float32 accumulated ~0.017 error across 6 matrix multiplications
(RGB→LMS→Lab→Lab→LMS→RGB), producing negative linear-green values that got
clamped to 0 by toSRGB().

Fix: all matrix math now uses Double. Forward LMS matrix corrected to match
Björn Ottosson's reference implementation. Variable shadowing eliminated.

All 3 OklabTest cases now pass (were failing since initial commit).
2026-06-21 15:10:25 +02:00
Bruno Deanoz
e3d624c961 feat: orb uses rememberTiltState(), update CLAUDE.md with i18n + migration status
KaizenOrb now uses centralized rememberTiltState() (GAME_ROTATION_VECTOR,
lifecycle-aware, battery-saver-aware) instead of inline sensor code.
Breath animation uses Durations tokens from motion system.

CLAUDE.md: updated design system section with GlassSurface correction
(no backdrop-filter on Android), migration status (all components done),
i18n section documenting the de/en string resource architecture.
2026-06-21 15:04:31 +02:00
Bruno Deanoz
0237eae1e6 feat: i18n — extract all UI strings to resources (de + en)
German (default) in values/strings.xml, English in values-en/strings.xml.
Matches web app's next-intl de/en setup.

Migrated: greeting, hero headline, chat input, error messages, mode pills,
suggestion pills, sidebar (brand, search, dates, logout, settings),
model sheet, login screen (welcome, fields, errors, submit button),
conversation labels (locked, pinned).

ChatModels: Suggestion/ChatMode now use resource IDs instead of hardcoded strings.
greeting() is now @Composable (uses stringResource).
ModePillsRow: selected state uses resource ID (Int) instead of String.
Date labels (HEUTE/GESTERN/FRÜHER) localized via DateLabels data class.
2026-06-21 15:02:16 +02:00
Bruno Deanoz
94c0f85fb7 feat: migrate all UI components to design system v2
- Sidebar: GlassSurface + KaizenShapes (lg, sm, md, pill, circle)
- ChatInput: GlassSurface with input tier opacity
- ChatScreen: menu button uses GlassSurface, error banner uses KaizenShapes
- EmptyHero: suggestion chips removed on mobile (DESIGN.md §11.1)
- LoginScreen: GlassFields use KaizenShapes.lg
- ModelSheet: KaizenShapes (xl, md, xs, circle)
- SettingsCard: GlassSurface with card tier opacity
- SettingsScreen: back button uses GlassSurface
- SettingsComponents: all CircleShape/RoundedCornerShape → KaizenShapes
- Markdown: copy button uses KaizenShapes.circle
- GlassSurface: removed misleading Modifier.blur (Android has no backdrop-filter)
- TypingDots: uses motion duration tokens
- Fixed deprecated ArrowBack icon → AutoMirrored variant
2026-06-21 14:50:11 +02:00
Bruno Deanoz
4d71475995 fix: derive all theme colors from web CSS files (OKLCH → P3 RGB)
Amber blobs were cold (blue/indigo) but web uses warm (gold/coral/honey/teal).
Mono had slate chroma but web is pure achromatic (zero chroma).
Aurora dark primary was magenta but web swaps to gold in dark mode.
All four themes now pixel-match the web app's OKLCH values.
2026-06-21 14:41:23 +02:00
Bruno Deanoz
f1c6a88c4c docs: add CLAUDE.md as architecture map (renamed from NEXT.md)
Adds design system v2 section covering P3 colors, squircle shapes,
GlassSurface, multi-layer shadows, 4-theme system, sensor tilt state,
motion tokens, and new test inventory.
2026-06-21 14:35:45 +02:00
Bruno Deanoz
6465b47bbb feat: design system v2 — P3 colors, squircle shapes, glass surfaces, multi-theme, motion tokens
Foundation for the premium UI overhaul:
- P3 DisplayP3 color pipeline (all tokens in wide gamut)
- SquircleShape (iOS-style superellipse with G2 continuity)
- KaizenShapes token system (xs/sm/md/lg/xl/pill/circle)
- Multi-layer ShadowStack system (level 0–4)
- GlassSurface composable with RenderEffect blur + API fallback
- Motion tokens (easing curves, spring specs, durations)
- Tilt sensor state (GAME_ROTATION_VECTOR, lifecycle-aware)
- 4-theme system (Amber/Blau/Mono/Aurora) with AccentScheme
- ThemeRegistry + CompositionLocal for accent propagation
- Unit tests for colors, shapes, shadows, themes
2026-06-21 14:32:25 +02:00
Bruno Deanoz
203316b1c3 fix: enable encodeDefaults so message persistence actually works
kotlinx.serialization defaults to not encoding properties with default
values. SaveMessage.kind = "chat" was never sent, causing the backend's
sanitizeSaveableMessage() to reject every save with 400. This also
broke title generation since it was gated behind a successful save.
2026-06-21 13:43:24 +02:00
Bruno Deanoz
9759d478a7 feat: offline-first cache layer with Room
Room 2.8.4 as local SQLite read-cache for conversations and messages.
Sidebar and chat history load instantly from cache, background refresh
syncs with the server. Streaming stays in-memory, flushed to Room after
completion. Includes ChatViewModel, repositories, mappers, TypeConverters,
and unit tests.
2026-06-21 12:50:46 +02:00
Bruno Deanoz
d8dcb25e3d feat: attachment support — display, upload, and send files in chat
- Parse and render message attachments from loaded conversations (images
  inline via OkHttp + in-memory cache, other kinds as icon chips)
- Wire up the + button with Android file picker (ActivityResultContracts)
- Upload files to /api/v1/upload with progress tracking and preview chips
- Send attachments with chat requests and persist them in saved messages
- Markdown: horizontal rules, blockquotes, links, code block copy button
2026-06-20 00:36:07 +02:00
76 changed files with 5089 additions and 1373 deletions

302
CLAUDE.md Normal file
View 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 24 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`.
5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. 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 (W100W900). 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
View 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 2630 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 2630 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 2630 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 2630, `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 (1632 sp body, 3248 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` (100900) and `opsz` (1432) 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 (520560) 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 (2432 sp) we set `opsz = 2432`, which makes Inter pick its "Display" outlines — slightly narrower, tighter, more elegant. At body sizes (1416 sp) we set `opsz = 1416` → "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 |
| --------- | -------------------------------------- | --------------------------------------------- |
| 96160 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 2630 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. |

423
OFFLINE_CACHE_ADD.md Normal file
View file

@ -0,0 +1,423 @@
# ADD: Offline-First Cache Layer (Room)
> Architecture Decision Document — Kaizen Android App
> Status: Draft v2, 2026-06-21
> Wichtig: Vor der Implementierung muss der aktuelle Code-Stand erneut geprüft werden. Dieses Dokument beschreibt die Architektur-Entscheidung und den Plan, nicht den exakten Code. Dateipfade, Signaturen und Patterns koennen sich seit dem Schreiben geaendert haben.
---
## 1. Problem
Die App hat keine lokale Datenbank. Jeder App-Start, jeder Sidebar-Swipe und jeder Chat-Wechsel blockiert auf einen Server-Fetch. Ohne Netzwerk ist die App komplett leer.
**Auswirkung auf die UX:**
- Sidebar oeffnet sich mit Ladezeit statt sofort
- Chat-Wechsel hat spuerbare Latenz (Netzwerk-RTT)
- Flugmodus / schlechtes Netz = leerer Bildschirm
- Kein Sofortstart-Gefuehl wie bei ChatGPT, Gemini, etc.
**Die Konkurrenz:** ChatGPT, Gemini und Claude nutzen lokale Offline-First-Datenbanken. Chats oeffnen sich instant (0ms), waehrend im Hintergrund mit dem Server synchronisiert wird.
---
## 2. Entscheidung
**Room** (Jetpack, SQLite-Wrapper) als lokaler Read-Cache.
### Warum Room:
- Compose-native: DAOs geben `Flow<List<T>>` zurueck -> `collectAsState()` in der UI
- Bereits im Jetpack-Stack (Compose, EncryptedSharedPreferences, Lifecycle)
- Compile-time Query-Checks
- Eingebauter Migrations-Support (bzw. `fallbackToDestructiveMigration()` fuer einen Cache)
- Groesstes Oekosystem, beste Doku
- Skaliert fuer die Zukunft: FTS-Suche, Media-Cache-Tracking, Attachment-Queries
### Verworfene Alternativen:
| Option | Grund gegen |
|--------|-------------|
| SQLDelight | Mehr Setup (eigenes Gradle-Plugin, raw SQL), weniger Compose-Integration |
| ObjectBox | Kleine Community, Vendor-Lock-in-Risiko |
| DataStore | Nur Key-Value, keine Queries |
| Raw SQLite | Kein Grund, das Rad neu zu erfinden |
| SharedPreferences JSON | Skaliert nicht fuer Messages (laedt alles in RAM), keine Queries |
### Plattform-Strategie:
Android = Room (Jetpack-nativ). Die iOS-App wird separat in Swift/SwiftUI mit SwiftData/CoreData gebaut — jede Plattform nutzt ihren nativen Stack. Kein KMP.
---
## 3. Architektur
### Prinzip: Read-Cache, nicht Sync-Engine
```
Server (PostgreSQL) = Source of Truth
Room (SQLite) = Lokaler Cache
Writes = Immer direkt an den Server
```
Kein Offline-Senden, kein Conflict-Resolution, kein Sync-Queue. Das waere ein riesiger Komplexitaetssprung, der aktuell keinen Mehrwert bringt.
### Streaming-Hybrid-Modell
Waehrend eines aktiven Streams werden Messages 60x/s in-memory mutiert. Room in dieser Frequenz zu beschreiben waere absurder I/O. Daher:
```
Historische Messages -> aus Room (Flow<List<MessageEntity>>)
Aktive Streaming-Msg -> in-memory (mutableStateOf<Message>)
Nach Stream-Ende -> in Room flushen (upsert)
```
Room ist Source of Truth fuer **ruhende** Daten. Waehrend des Streams ist der in-memory State fuehrend. Nach Abschluss werden die neuen Messages in Room geschrieben und der Flow aktualisiert die UI automatisch.
### Datenfluss (vorher vs. nachher)
```
VORHER:
App oeffnen -> fetch /api/v1/conversations (500ms+) -> anzeigen
Chat oeffnen -> fetch /api/v1/conversations/[id] (300ms+) -> anzeigen
NACHHER:
App oeffnen -> Room lesen (0ms) -> sofort anzeigen
-> Background: Server fetchen -> Room updaten -> UI reaktiv aktualisiert
Chat oeffnen -> Room lesen (0ms) -> sofort anzeigen
-> Background: Server fetchen (falls noetig) -> Room updaten
```
### Schichten
```
┌──────────────────────────────────┐
│ UI (Compose) │ ChatScreen, Sidebar, etc.
│ collectAsState(Flow<T>) │ Beobachtet Room-Flows
├──────────────────────────────────┤
│ ChatViewModel │ Haelt Room-Flows + Streaming-State
│ (wie SessionViewModel, manuell) │ Kein Hilt/Koin noetig
├──────────────────────────────────┤
│ Repository Layer │ Entscheidet: Room oder Server?
│ ConversationRepository │ Steuert Background-Refresh
│ MessageRepository │
├───────────────┬──────────────────┤
│ Room (DAO) │ KaizenApi │ Lokaler Cache │ Server-Calls
│ Flow<T> │ suspend fun │ (unveraendert)
└───────────────┴──────────────────┘
```
---
## 4. Room-Schema
### TypeConverter
```kotlin
class Converters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun attachmentsToJson(attachments: List<Attachment>): String =
json.encodeToString(attachments)
@TypeConverter
fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
}
```
### Entities
```kotlin
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val id: String,
val title: String,
val locked: Boolean,
val pinned: Boolean,
val updatedAt: String?,
val cachedAt: Long, // System.currentTimeMillis(), fuer Stale-Check
)
@Entity(
tableName = "messages",
foreignKeys = [ForeignKey(
entity = ConversationEntity::class,
parentColumns = ["id"],
childColumns = ["conversationId"],
onDelete = ForeignKey.CASCADE,
)],
indices = [Index("conversationId")],
)
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val role: String, // "user" | "assistant"
val content: String,
val attachments: List<Attachment>,
val sortOrder: Int,
)
```
### Mapper-Funktionen
```kotlin
fun ConversationSummary.toEntity() = ConversationEntity(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
cachedAt = System.currentTimeMillis(),
)
fun ConversationEntity.toSummary() = ConversationSummary(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
)
fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(
id = id,
conversationId = conversationId,
role = role,
content = content,
attachments = attachments,
sortOrder = sortOrder,
)
fun MessageEntity.toStoredMessage() = StoredMessage(
id = id,
role = role,
content = content,
attachments = attachments,
)
```
### DAOs
```kotlin
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY pinned DESC, updatedAt DESC")
fun observeAll(): Flow<List<ConversationEntity>>
@Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
@Transaction
suspend fun replaceAll(conversations: List<ConversationEntity>) {
deleteAll()
upsertAll(conversations)
}
@Query("SELECT cachedAt FROM conversations LIMIT 1")
suspend fun lastCachedAt(): Long?
}
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
fun observeByConversation(convId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
suspend fun getByConversation(convId: String): List<MessageEntity>
@Upsert
suspend fun upsertAll(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE conversationId = :convId")
suspend fun deleteByConversation(convId: String)
}
```
### Database
```kotlin
@Database(
entities = [ConversationEntity::class, MessageEntity::class],
version = 1,
)
@TypeConverters(Converters::class)
abstract class KaizenDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
companion object {
@Volatile private var instance: KaizenDatabase? = null
fun get(context: Context): KaizenDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
KaizenDatabase::class.java,
"kaizen-cache",
)
.fallbackToDestructiveMigration()
.build()
.also { instance = it }
}
}
}
```
`fallbackToDestructiveMigration()` ist bewusst: Room ist ein Cache, kein Source of Truth. Bei Schema-Aenderungen wird die DB weggeworfen und beim naechsten Server-Fetch neu gefuellt. Keine Migrations noetig.
---
## 5. Repository Layer
```kotlin
class ConversationRepository(
private val dao: ConversationDao,
) {
fun observeAll(): Flow<List<ConversationSummary>> =
dao.observeAll().map { list -> list.map { it.toSummary() } }
suspend fun refresh(baseUrl: String, token: String) {
when (val r = KaizenApi.fetchConversations(baseUrl, token)) {
is FetchResult.Ok -> {
val entities = r.data.map { it.toEntity() }
dao.replaceAll(entities)
}
is FetchResult.Fail -> { /* Cache bleibt stehen, kein Crash */ }
}
}
}
class MessageRepository(
private val dao: MessageDao,
) {
fun observeMessages(convId: String): Flow<List<MessageEntity>> =
dao.observeByConversation(convId)
suspend fun fetchAndCache(baseUrl: String, token: String, convId: String) {
val stored = KaizenApi.fetchMessages(baseUrl, token, convId)
val entities = stored.mapIndexed { i, m -> m.toEntity(convId, i) }
dao.deleteByConversation(convId)
dao.upsertAll(entities)
}
suspend fun cacheMessages(convId: String, messages: List<MessageEntity>) {
dao.upsertAll(messages)
}
}
```
---
## 6. ChatViewModel
Neuer ViewModel, analog zu `SessionViewModel` — manuell instanziiert, kein DI.
```kotlin
class ChatViewModel(context: Context) {
private val db = KaizenDatabase.get(context)
val conversationRepo = ConversationRepository(db.conversationDao())
val messageRepo = MessageRepository(db.messageDao())
}
```
Instanziierung in `MainActivity`, weitergabe an `ChatScreen` wie `SessionViewModel`.
---
## 7. UI-Aenderungen (Uebersicht)
### Sidebar
- Vorher: `conversations` ist ein `var` State, gefuellt durch `fetchConversations()`
- Nachher: `chatViewModel.conversationRepo.observeAll().collectAsState()` — instant, reaktiv
- Background-Refresh beim App-Start und bei `LifecycleResumeEffect`
### ChatScreen — openConversation()
- Vorher: `fetchMessages()` blockiert, dann `messages.addAll()`
- Nachher: Sofort aus Room laden (falls cached), parallel vom Server refreshen
- Room-Flow fuellt die historischen Messages, Streaming-Message bleibt in-memory
### ChatScreen — send()
- Waehrend des Streams: `messages` bleibt `mutableStateListOf` (in-memory, wie bisher)
- Nach Stream-Ende: `messageRepository.cacheMessages()` zusaetzlich zum bestehenden `saveMessages()`
- `conversationRepo.refresh()` aktualisiert die Sidebar reaktiv ueber den Room-Flow
### Streaming-Hybrid im Detail
```
1. User oeffnet Chat -> Room-Flow liefert gecachte Messages (instant)
-> Background: Server-Fetch -> Room-Update -> Flow emittiert neu
2. User sendet Message -> In-memory State fuehrt (wie bisher)
-> Stream laeuft, messages[idx].copy(content=...) in-memory
3. Stream endet -> messageRepo.cacheMessages(convId, newMessages)
-> conversationRepo.refresh() fuer Sidebar-Update
4. User wechselt Chat -> Zurueck zu Schritt 1
```
### Stale-Check
- `cachedAt` auf jeder Conversation — wenn aelter als z.B. 5 Minuten, Background-Refresh
- App-Start: immer refreshen, aber UI zeigt sofort den Cache
---
## 8. Neue Dependencies
```toml
# gradle/libs.versions.toml
room = "2.7.1" # Exakte Version beim Implementieren pruefen!
ksp = "..." # Muss zur Kotlin-Version passen (2.2.10)
# libraries
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# plugins
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
```
```kotlin
// build.gradle.kts
plugins {
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
}
```
---
## 9. Implementierungs-Reihenfolge
| Schritt | Was | Abhaengigkeit |
|---------|-----|---------------|
| 1 | Room-Dependencies + KSP Plugin | — |
| 2 | TypeConverters + Entities + DAOs + KaizenDatabase (mit Singleton + fallbackToDestructiveMigration) | Schritt 1 |
| 3 | Mapper-Funktionen (toEntity/toSummary/toStoredMessage) | Schritt 2 |
| 4 | ConversationRepository + MessageRepository | Schritt 3 |
| 5 | ChatViewModel + Instanziierung in MainActivity | Schritt 4 |
| 6 | Sidebar auf Room-Flow umstellen | Schritt 5 |
| 7 | openConversation() auf Room umstellen (Hybrid: Room + Server-Refresh) | Schritt 5 |
| 8 | send() cached neue Messages in Room nach Stream-Ende | Schritt 5 |
---
## 10. Was sich NICHT aendert
- **KaizenApi** bleibt unveraendert — reine HTTP-Calls, kein Room-Wissen
- **StreamConsumer** bleibt unveraendert — Streaming-Parsing ist orthogonal zum Cache
- **Senden ist weiterhin nur online** — kein Offline-Queue, kein Conflict-Resolution
- **Server bleibt Source of Truth** — Room ist ein Cache, keine eigene Datenhaltung
- **SecureStore / ServerConfig** bleiben in EncryptedSharedPreferences
- **Models-Cache** bleibt in SecureStore (klein, flach, funktioniert)
---
## 11. Zukunft (nicht Teil dieser Iteration)
- **Offline-Queue fuer Sends** — Messages lokal queuen, bei Netzwerk senden (braucht Conflict-Resolution)
- **Inkrementeller Sync** — nur geaenderte Conversations holen statt die volle Liste (braucht Server-seitigen `since`-Parameter)
- **Media-Cache** — Bilder/Dateien lokal cachen (Coil oder eigener Disk-Cache)
- **Volltextsuche** — FTS4/FTS5 ueber gecachte Messages (Room unterstuetzt das nativ)
- **Attachment-Queries** — "Zeig mir alle Chats mit Bildern/PDFs" ueber die Room-DB

View file

@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
} }
android { android {
@ -56,6 +57,9 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences) // Hardware-backed encrypted storage for the bearer token (EncryptedSharedPreferences)
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
// Room offline cache (local SQLite read-cache for conversations + messages)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -7,12 +7,15 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen import dev.kaizen.app.chat.ChatScreen
import dev.kaizen.app.chat.ChatViewModel
import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
import dev.kaizen.app.settings.AppAppearance
import dev.kaizen.app.ui.theme.KaizenTheme import dev.kaizen.app.ui.theme.KaizenTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -28,10 +31,10 @@ class MainActivity : ComponentActivity() {
window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
} }
// Initialize the decoupled viewmodels at the Activity scope (or Hilt/Dagger later) val secureStore = SecureStore(applicationContext)
val settingsViewModel = SettingsViewModel() val settingsViewModel = SettingsViewModel(secureStore)
// Restores the persisted (encrypted) server config — drives login vs. chat routing val sessionViewModel = SessionViewModel(secureStore)
val sessionViewModel = SessionViewModel(SecureStore(applicationContext)) val chatViewModel = ChatViewModel(applicationContext)
// auto() picks light/dark system-bar icons based on the system theme // auto() picks light/dark system-bar icons based on the system theme
enableEdgeToEdge( enableEdgeToEdge(
@ -39,11 +42,18 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
) )
setContent { 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 // Snapshot-state routing: logging in/out flips session.config and
// recomposes this branch — no NavHost needed for two top-level states. // recomposes this branch — no NavHost needed for two top-level states.
if (sessionViewModel.isLoggedIn) { if (sessionViewModel.isLoggedIn) {
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel) ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel)
} else { } else {
LoginScreen(session = sessionViewModel) LoginScreen(session = sessionViewModel)
} }

View file

@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState 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.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.Amber
import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.OnAmber import dev.kaizen.app.ui.theme.OnAmber
import androidx.compose.ui.res.stringResource
import dev.kaizen.app.R
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@ -64,6 +66,7 @@ import kotlinx.coroutines.launch
fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) { fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val ctx = androidx.compose.ui.platform.LocalContext.current
var baseUrl by remember { mutableStateOf(DEFAULT_BASE_URL) } var baseUrl by remember { mutableStateOf(DEFAULT_BASE_URL) }
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("") }
@ -81,9 +84,9 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) {
scope.launch { scope.launch {
when (val result = session.login(baseUrl, email, password, deviceName)) { when (val result = session.login(baseUrl, email, password, deviceName)) {
is LoginResult.Success -> Unit // session.config flips → MainActivity routes to chat is LoginResult.Success -> Unit // session.config flips → MainActivity routes to chat
LoginResult.InvalidCredentials -> error = "E-Mail oder Passwort ist falsch." LoginResult.InvalidCredentials -> error = ctx.getString(R.string.login_error_credentials)
LoginResult.RateLimited -> error = "Zu viele Versuche. Bitte kurz warten." LoginResult.RateLimited -> error = ctx.getString(R.string.login_error_rate_limited)
is LoginResult.Error -> error = "Verbindung fehlgeschlagen. Server-Adresse prüfen." is LoginResult.Error -> error = ctx.getString(R.string.login_error_connection)
} }
loading = false loading = false
} }
@ -102,10 +105,10 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) {
Spacer(Modifier.size(64.dp)) Spacer(Modifier.size(64.dp))
KaizenOrb(96.dp) KaizenOrb(96.dp)
Spacer(Modifier.size(24.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)) Spacer(Modifier.size(6.dp))
Text( Text(
"Melde dich bei deiner Kaizen-Instanz an", stringResource(R.string.login_subtitle),
color = cs.onSurfaceVariant.copy(alpha = 0.8f), color = cs.onSurfaceVariant.copy(alpha = 0.8f),
fontSize = 14.sp, fontSize = 14.sp,
) )
@ -115,7 +118,7 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) {
GlassField( GlassField(
value = baseUrl, value = baseUrl,
onValueChange = { baseUrl = it; error = null }, onValueChange = { baseUrl = it; error = null },
placeholder = "https://deine-instanz.de", placeholder = stringResource(R.string.login_url_placeholder),
keyboardType = KeyboardType.Uri, keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
) )
@ -123,7 +126,7 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) {
GlassField( GlassField(
value = email, value = email,
onValueChange = { email = it; error = null }, onValueChange = { email = it; error = null },
placeholder = "E-Mail", placeholder = stringResource(R.string.login_email_placeholder),
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next, imeAction = ImeAction.Next,
) )
@ -131,7 +134,7 @@ fun LoginScreen(session: SessionViewModel, modifier: Modifier = Modifier) {
GlassField( GlassField(
value = password, value = password,
onValueChange = { password = it; error = null }, onValueChange = { password = it; error = null },
placeholder = "Passwort", placeholder = stringResource(R.string.login_password_placeholder),
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
password = true, password = true,
@ -178,10 +181,10 @@ private fun GlassField(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.widthIn(max = 420.dp) .widthIn(max = 420.dp)
.shadow(elevation = 6.dp, shape = RoundedCornerShape(20.dp), clip = false) .shadow(elevation = 6.dp, shape = KaizenShapes.lg, clip = false)
.clip(RoundedCornerShape(20.dp)) .clip(KaizenShapes.lg)
.background(glassBg) .background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(20.dp)) .border(1.2.dp, glassBorder, KaizenShapes.lg)
.heightIn(min = 54.dp) .heightIn(min = 54.dp)
.padding(horizontal = 18.dp, vertical = 16.dp), .padding(horizontal = 18.dp, vertical = 16.dp),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
@ -216,8 +219,8 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit
.fillMaxWidth() .fillMaxWidth()
.widthIn(max = 420.dp) .widthIn(max = 420.dp)
.heightIn(min = 54.dp) .heightIn(min = 54.dp)
.shadow(elevation = 8.dp, shape = RoundedCornerShape(20.dp), clip = false) .shadow(elevation = 8.dp, shape = KaizenShapes.lg, clip = false)
.clip(RoundedCornerShape(20.dp)) .clip(KaizenShapes.lg)
.then(fill) .then(fill)
.clickable(enabled = enabled, onClick = onClick) .clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 16.dp), .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)) CircularProgressIndicator(color = OnAmber, strokeWidth = 2.4.dp, modifier = Modifier.size(22.dp))
} else { } else {
Text( Text(
"Anmelden", stringResource(R.string.login_submit),
color = if (enabled) OnAmber else cs.onSurfaceVariant, color = if (enabled) OnAmber else cs.onSurfaceVariant,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,

View file

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

View file

@ -19,24 +19,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState 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.text.BasicTextField
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -46,13 +41,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -60,72 +55,63 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.Amber
import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.OnAmber 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.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlin.OptIn import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.material.icons.rounded.AudioFile
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.Image
import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
import androidx.compose.material.icons.rounded.PictureAsPdf
import androidx.compose.material.icons.rounded.VideoFile
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
@Composable @Composable
fun KaizenHeader(modifier: Modifier = Modifier) { fun KaizenHeader(modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() GlassSurface(
modifier = modifier
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
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp) .padding(horizontal = 16.dp, vertical = 10.dp),
.clip(RoundedCornerShape(24.dp)) shape = KaizenShapes.lg,
.background(glassBg) shadowStack = KaizenShadows.level1,
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) cornerRadius = 24.dp,
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
KaizenOrb(24.dp) Row(
Spacer(Modifier.width(10.dp)) Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) verticalAlignment = Alignment.CenterVertically,
) {
KaizenOrb(24.dp)
Spacer(Modifier.width(10.dp))
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
} }
} }
@ -138,11 +124,6 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN)) 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( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -150,88 +131,29 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, 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) KaizenOrb(104.dp)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp) Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
Spacer(Modifier.height(6.dp)) 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)) Spacer(Modifier.height(8.dp))
Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp) Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp)
Spacer(Modifier.height(142.dp))
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)
}
}
} }
} }
@Composable @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 cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val glassBorderInactive = remember(isDark) { val glassBorderInactive = remember(isDark) {
if (isDark) { if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.02f)))
Brush.verticalGradient( else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.45f), Color.Black.copy(alpha = 0.04f)))
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) { val glassBorderActive = remember(isDark) {
Brush.verticalGradient( Brush.verticalGradient(listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f)))
listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f))
)
} }
Row( Row(
@ -242,37 +164,24 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
chatModes.forEach { mode -> chatModes.forEach { mode ->
val active = mode.label == selected val label = stringResource(mode.labelRes)
val backgroundFill = if (active) { val active = mode.labelRes == selected
Amber.copy(alpha = 0.16f) val backgroundFill = if (active) Amber.copy(alpha = 0.16f)
} else { else if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
}
val borderBrush = if (active) glassBorderActive else glassBorderInactive val borderBrush = if (active) glassBorderActive else glassBorderInactive
Row( Row(
Modifier Modifier
.shadow(elevation = 2.dp, shape = CircleShape, clip = false) // Floating tabs .clip(KaizenShapes.pill)
.clip(CircleShape)
.background(backgroundFill) .background(backgroundFill)
.border(1.2.dp, borderBrush, CircleShape) .border(1.2.dp, borderBrush, KaizenShapes.pill)
.clickable { onSelect(mode.label) } .clickable { onSelect(mode.labelRes) }
.padding(horizontal = 13.dp, vertical = 8.dp), .padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(mode.icon, null, tint = if (active) Amber else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
mode.icon,
null,
tint = if (active) Amber else cs.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text( Text(label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium)
mode.label,
color = if (active) cs.onBackground else cs.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
} }
} }
} }
@ -282,39 +191,30 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
fun MessageRow(message: Message) { fun MessageRow(message: Message) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val hasAttachments = message.attachments.isNotEmpty()
if (message.role == Role.User) { if (message.role == Role.User) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp) val shape = KaizenShapes.lg
val borderBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f))) }
// Specular highlighting and gradient matching the oklch brand colors val bgBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f))) }
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( Box(
Modifier Modifier
.widthIn(max = 300.dp) .widthIn(max = 300.dp)
.shadow(elevation = 3.dp, shape = shape, clip = false) // Raised amber glass bubble!
.clip(shape) .clip(shape)
.background(bgBrush) .background(bgBrush)
.border(1.2.dp, borderBrush, shape) .border(1.2.dp, borderBrush, shape),
.padding(horizontal = 16.dp, vertical = 11.dp),
) { ) {
Text(message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp) Column {
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),
)
}
}
} }
} }
} else { } else {
@ -322,13 +222,25 @@ fun MessageRow(message: Message) {
KaizenOrb(28.dp, streaming = message.streaming) KaizenOrb(28.dp, streaming = message.streaming)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) { Box(Modifier.weight(1f).padding(top = 3.dp)) {
if (message.thinking) { Column {
TypingDots() if (message.tools.isNotEmpty()) {
} else { ToolEventsBlock(message.tools, streaming = message.streaming)
MarkdownText( Spacer(Modifier.height(8.dp))
text = message.content, }
streaming = message.streaming, 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.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)
}
} }
} }
} }
@ -342,135 +254,143 @@ private fun TypingDots() {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
repeat(3) { i -> repeat(3) { i ->
val alpha by transition.animateFloat( val alpha by transition.animateFloat(
initialValue = 0.25f, initialValue = 0.25f, targetValue = 1f,
targetValue = 1f,
animationSpec = infiniteRepeatable( 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, RepeatMode.Reverse,
), ),
label = "dot$i", label = "dot$i",
) )
Box( Box(Modifier.padding(end = 5.dp).size(7.dp).clip(KaizenShapes.circle).background(cs.onSurfaceVariant.copy(alpha = alpha)))
Modifier
.padding(end = 5.dp)
.size(7.dp)
.clip(CircleShape)
.background(cs.onSurfaceVariant.copy(alpha = alpha)),
)
} }
} }
} }
data class PendingFile(
val id: String = java.util.UUID.randomUUID().toString(),
val name: String,
val mimeType: String,
val bytes: ByteArray,
val uploaded: Attachment? = null,
val error: String? = null,
val uploading: Boolean = true,
)
@Composable @Composable
fun ChatInput( fun ChatInput(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
enabled: Boolean, enabled: Boolean,
pendingFiles: List<PendingFile> = emptyList(),
onAddClick: () -> Unit = {},
onRemoveFile: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val glassBg = remember(isDark) { GlassSurface(
if (isDark) { modifier = modifier
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)
)
)
}
}
Row(
modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp),
.shadow(elevation = 8.dp, shape = RoundedCornerShape(28.dp), clip = false) // Luxurious floating ambient shadow! shape = KaizenShapes.xl,
.clip(RoundedCornerShape(28.dp)) tintAlpha = GlassTiers.input(isDark),
.background(glassBg) shadowStack = KaizenShadows.level2,
.border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)) cornerRadius = 28.dp,
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
GhostIconButton(Icons.Rounded.Add, "Anhang") Column {
Box( if (pendingFiles.isNotEmpty()) {
Modifier Row(
.weight(1f) Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp),
.heightIn(min = 36.dp) horizontalArrangement = Arrangement.spacedBy(6.dp),
.padding(horizontal = 4.dp), ) {
contentAlignment = Alignment.CenterStart pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
) { }
if (value.isEmpty()) { }
Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp) Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Add, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
}
Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) Text(stringResource(R.string.chat_input_placeholder), color = cs.onSurfaceVariant, fontSize = 16.sp)
BasicTextField(
value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(Amber), maxLines = 6, modifier = Modifier.fillMaxWidth(),
)
}
GhostIconButton(Icons.Rounded.Call, "Anruf")
Spacer(Modifier.width(4.dp))
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
SendButton(enabled = canSend, onClick = onSend)
} }
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(Amber),
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
)
} }
GhostIconButton(Icons.Rounded.Call, "Anruf")
Spacer(Modifier.width(4.dp))
SendButton(enabled = enabled && value.isNotBlank(), onClick = onSend)
} }
} }
@Composable @Composable
private fun GhostIconButton(icon: ImageVector, contentDescription: String) { private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
val cs = MaterialTheme.colorScheme 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)) Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
} }
} }
@Composable
private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
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(KaizenShapes.xs).background(bg), contentAlignment = Alignment.Center) {
if (isImage && pf.uploaded != null) {
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
pf.mimeType.startsWith("video/") -> Icons.Rounded.VideoFile
pf.mimeType.startsWith("audio/") -> Icons.Rounded.AudioFile
pf.mimeType == "application/pdf" -> Icons.Rounded.PictureAsPdf
else -> Icons.AutoMirrored.Rounded.InsertDriveFile
}
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))
}
}
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)
}
}
if (pf.error != null) {
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(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))
}
}
}
@Composable @Composable
private fun SendButton(enabled: Boolean, onClick: () -> Unit) { private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send") val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send")
val fill = if (enabled) { val fill = if (enabled) Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep))) else Modifier.background(cs.surface)
} else {
Modifier.background(cs.surface)
}
Box( Box(
Modifier Modifier.size(44.dp).scale(pressScale).clip(KaizenShapes.circle).then(fill)
.size(44.dp) .border(1.dp, cs.outline, KaizenShapes.circle)
.scale(pressScale)
.clip(CircleShape)
.then(fill)
.border(1.dp, cs.outline, CircleShape)
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick), .clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -478,14 +398,80 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
} }
} }
/** Crisp upward arrow drawn with strokes (sharper than a glyph). */ // ── Attachment rendering ────────────────────────────────────────────────────
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()
suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) {
synchronized(cache) { cache[url] }?.let { return@withContext it }
try {
val req = Request.Builder().url(url).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null
val bytes = resp.body!!.bytes()
val probe = BitmapFactory.Options().apply { inJustDecodeBounds = true }
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
bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } }
}
} catch (_: Exception) { null }
}
}
@Composable
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)
} else {
Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)))
}
}
@Composable
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(KaizenShapes.xs))
}
others.forEach { att -> FileChip(att) }
}
}
@Composable
private fun FileChip(attachment: Attachment) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val bg = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
val icon = when (attachment.kind) {
"pdf" -> Icons.Rounded.PictureAsPdf
"audio" -> Icons.Rounded.AudioFile
"video" -> Icons.Rounded.VideoFile
"code" -> Icons.Rounded.Code
else -> Icons.AutoMirrored.Rounded.InsertDriveFile
}
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)
}
}
@Composable @Composable
private fun SendArrow(color: Color) { private fun SendArrow(color: Color) {
Canvas(Modifier.size(18.dp)) { Canvas(Modifier.size(18.dp)) {
val w = size.width val w = size.width; val h = size.height; val cx = w / 2f; val stroke = w * 0.135f
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.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.28f, h * 0.46f), stroke, StrokeCap.Round)
drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.72f, h * 0.46f), stroke, StrokeCap.Round) drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.72f, h * 0.46f), stroke, StrokeCap.Round)

View file

@ -8,7 +8,13 @@ import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Movie import androidx.compose.material.icons.rounded.Movie
import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Tune
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector 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 } enum class Role { User, Assistant }
@ -18,36 +24,38 @@ data class Message(
val content: String, val content: String,
val streaming: Boolean = false, val streaming: Boolean = false,
val thinking: Boolean = false, val thinking: Boolean = false,
// Stable server-side id (UUID) used for persistence + parentId chaining. Generated
// on creation; overwritten with the DB id when loading an existing conversation.
val wireId: String = java.util.UUID.randomUUID().toString(), 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 labelRes: Int, val promptRes: Int, val icon: ImageVector)
data class Suggestion(val label: String, val prompt: String, val icon: ImageVector)
val suggestions = listOf( val suggestions = listOf(
Suggestion("Brainstormen", "Lass uns zu einem Thema brainstormen.", Icons.Rounded.AutoAwesome), Suggestion(R.string.suggest_brainstorm, R.string.suggest_brainstorm_prompt, Icons.Rounded.AutoAwesome),
Suggestion("Bild generieren", "Generiere ein Bild von einem Bergsee bei Sonnenaufgang.", Icons.Rounded.Image), Suggestion(R.string.suggest_image, R.string.suggest_image_prompt, Icons.Rounded.Image),
Suggestion("Video erstellen", "Erstelle ein kurzes Video von einer Stadt bei Nacht.", Icons.Rounded.Movie), Suggestion(R.string.suggest_video, R.string.suggest_video_prompt, Icons.Rounded.Movie),
Suggestion("Web durchsuchen", "Durchsuche das Web nach den neuesten KI-Nachrichten.", Icons.Rounded.Language), 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 labelRes: Int, val icon: ImageVector)
data class ChatMode(val label: String, val icon: ImageVector)
val chatModes = listOf( val chatModes = listOf(
ChatMode("Standard", Icons.Rounded.Adjust), ChatMode(R.string.mode_standard, Icons.Rounded.Adjust),
ChatMode("Sampling", Icons.Rounded.Tune), ChatMode(R.string.mode_sampling, Icons.Rounded.Tune),
ChatMode("Bild", Icons.Rounded.Image), ChatMode(R.string.mode_image, Icons.Rounded.Image),
ChatMode("Suche", Icons.Rounded.Language), ChatMode(R.string.mode_search, Icons.Rounded.Language),
ChatMode("Video", Icons.Rounded.Movie), ChatMode(R.string.mode_video, Icons.Rounded.Movie),
ChatMode("Audio", Icons.Rounded.MusicNote), ChatMode(R.string.mode_audio, Icons.Rounded.MusicNote),
) )
@Composable
fun greeting(hour: Int): String = when (hour) { fun greeting(hour: Int): String = when (hour) {
in 5..11 -> "Guten Morgen" in 5..11 -> stringResource(R.string.greeting_morning)
in 12..17 -> "Guten Tag" in 12..17 -> stringResource(R.string.greeting_day)
in 18..22 -> "Guten Abend" in 18..22 -> stringResource(R.string.greeting_evening)
else -> "Hallo" else -> stringResource(R.string.greeting_night)
} }

View file

@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Icons
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
@ -45,7 +43,6 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@ -53,7 +50,15 @@ import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 import dev.kaizen.app.haptics.rememberHaptics
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ChatHttpException import dev.kaizen.app.net.ChatHttpException
import dev.kaizen.app.net.ChatSpeed import dev.kaizen.app.net.ChatSpeed
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
@ -65,6 +70,9 @@ import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.net.WireMessage import dev.kaizen.app.net.WireMessage
import dev.kaizen.app.settings.SettingsScreen import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -76,6 +84,7 @@ enum class AppScreen { Chat, Settings }
fun ChatScreen( fun ChatScreen(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
session: SessionViewModel, session: SessionViewModel,
chat: ChatViewModel,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val haptics = rememberHaptics() val haptics = rememberHaptics()
@ -85,7 +94,7 @@ fun ChatScreen(
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(false) } var isStreaming by remember { mutableStateOf(false) }
var nextId by remember { mutableStateOf(0L) } var nextId by remember { mutableStateOf(0L) }
var selectedMode by remember { mutableStateOf("Standard") } var selectedMode by remember { mutableStateOf(R.string.mode_standard) }
// Navigation State // Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) } var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
@ -104,12 +113,39 @@ fun ChatScreen(
var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) } var models by remember { mutableStateOf(session.store.loadModelsCache() ?: emptyList()) }
var showModelSheet by remember { mutableStateOf(false) } var showModelSheet by remember { mutableStateOf(false) }
// Conversation persistence: the active server conversation + the sidebar list // Conversation persistence: sidebar list from Room (instant), active conversation id
var conversationId by remember { mutableStateOf<String?>(null) } var conversationId by remember { mutableStateOf<String?>(null) }
var conversations by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) } val conversations by chat.conversationRepo.observeAll().collectAsState(initial = emptyList())
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
// Pending file attachments — uploaded immediately on pick, cleared on send.
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
val context = LocalContext.current
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri ?: return@rememberLauncherForActivityResult
val cfg = session.config ?: return@rememberLauncherForActivityResult
val cr = context.contentResolver
val mimeType = cr.getType(uri) ?: "application/octet-stream"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
val pf = PendingFile(name = fileName, mimeType = mimeType, bytes = bytes)
pendingFiles.add(pf)
scope.launch {
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, fileName, mimeType, bytes)) {
is FetchResult.Ok -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false)
}
is FetchResult.Fail -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false)
}
}
}
}
LaunchedEffect(session.config?.baseUrl, session.config?.token) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect val cfg = session.config ?: return@LaunchedEffect
loadError = null loadError = null
@ -122,10 +158,11 @@ fun ChatScreen(
} }
is FetchResult.Fail -> errors.add(r.reason) is FetchResult.Fail -> errors.add(r.reason)
} }
when (val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)) { KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me ->
is FetchResult.Ok -> conversations = r.data me.name?.let { settingsViewModel.updateUserName(it) }
is FetchResult.Fail -> errors.add(r.reason) me.email?.let { settingsViewModel.updateUserEmail(it) }
} }
chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ") loadError = errors.joinToString(" · ")
} }
@ -143,10 +180,7 @@ fun ChatScreen(
fun refreshConversations() { fun refreshConversations() {
val cfg = session.config ?: return val cfg = session.config ?: return
scope.launch { scope.launch { chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) }
val r = KaizenApi.fetchConversations(cfg.baseUrl, cfg.token)
if (r is FetchResult.Ok) conversations = r.data
}
} }
/** Start a fresh chat — drops the active conversation, the next send creates a new one. */ /** Start a fresh chat — drops the active conversation, the next send creates a new one. */
@ -155,44 +189,69 @@ fun ChatScreen(
conversationId = null conversationId = null
} }
/** Load a stored conversation into the linear message list. Locked ones are skipped (no in-app unlock yet). */
fun openConversation(summary: ConversationSummary) { fun openConversation(summary: ConversationSummary) {
if (summary.locked) return if (summary.locked) return
val cfg = session.config ?: return val cfg = session.config ?: return
scope.launch { scope.launch {
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id) // Instant: load from Room cache first
val cached = chat.messageRepo.observeMessages(summary.id)
messages.clear() messages.clear()
stored.forEach { m -> val cachedList = chat.messageRepo.getCached(summary.id)
cachedList.forEach { m ->
messages.add( messages.add(
Message( Message(
id = nextId++, id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User, role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content, content = m.content,
wireId = m.id, wireId = m.id,
attachments = m.attachments,
), ),
) )
} }
conversationId = summary.id conversationId = summary.id
// Background: refresh from server and update Room + UI
val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id)
if (stored.isNotEmpty()) {
val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) }
chat.messageRepo.cacheMessages(summary.id, entities)
// Only rebuild message list if content actually changed
if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) {
messages.clear()
stored.forEach { m ->
messages.add(
Message(
id = nextId++,
role = if (m.role == "assistant") Role.Assistant else Role.User,
content = m.content,
wireId = m.id,
attachments = m.attachments,
),
)
}
}
}
} }
} }
fun send(text: String) { fun send(text: String) {
val trimmed = text.trim() val trimmed = text.trim()
if (trimmed.isEmpty() || isStreaming) return val uploadedAtts = pendingFiles.mapNotNull { it.uploaded }
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
if (isStreaming) return
val cfg = session.config ?: return val cfg = session.config ?: return
haptics.click() haptics.click()
// parentId chains the linear history: the user msg hangs off the last message.
val userParentId = messages.lastOrNull()?.wireId val userParentId = messages.lastOrNull()?.wireId
val userMsg = Message(nextId++, Role.User, trimmed) val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts)
messages.add(userMsg) messages.add(userMsg)
input = "" input = ""
pendingFiles.clear()
val assistantId = nextId++ val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString() val assistantWireId = java.util.UUID.randomUUID().toString()
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
isStreaming = true isStreaming = true
val wasNewConversation = conversationId == null val wasNewConversation = conversationId == null
// Wire history = the visible conversation minus the streaming placeholder.
val history = messages val history = messages
.filter { it.id != assistantId } .filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) } .map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
@ -201,53 +260,75 @@ fun ChatScreen(
haptics.thinkingStart() haptics.thinkingStart()
var sawContent = false var sawContent = false
try { try {
// Launch conversation creation in parallel — the chat stream does not
// need a conversation id, only the post-stream persistence does. This
// eliminates a full network RTT from the TTFT on new chats.
val convIdDeferred = if (conversationId == null) { val convIdDeferred = if (conversationId == null) {
async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) } async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null } else null
KaizenApi.chat(cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed).collect { visible -> KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) { if (!sawContent && state.content.isNotEmpty()) {
sawContent = true sawContent = true
haptics.responseStart() haptics.responseStart()
} }
messages[idx] = messages[idx].copy( messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking, 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 } val idx = messages.indexOfLast { it.id == assistantId }
val finalContent = if (idx >= 0) messages[idx].content else "" val finalContent = if (idx >= 0) messages[idx].content else ""
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
// Await the conversation id — the create ran in parallel with the stream
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
// Persist the exchange (best-effort); auto-title + sidebar refresh on a new conversation.
if (convId != null && finalContent.isNotEmpty()) { if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages( val saved = KaizenApi.saveMessages(
cfg.baseUrl, cfg.token, convId, cfg.baseUrl, cfg.token, convId,
listOf( listOf(
SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = trimmed), SaveMessage(
id = userMsg.wireId, parentId = userParentId,
role = "user", content = trimmed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
),
SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model), SaveMessage(id = assistantWireId, parentId = userMsg.wireId, role = "assistant", content = finalContent, model = cfg.model),
), ),
) )
if (saved && wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) if (saved) {
if (saved) refreshConversations() // Cache new messages in Room for offline-first
val msgCount = messages.size
chat.messageRepo.cacheMessages(convId, listOf(
MessageEntity(
id = userMsg.wireId, conversationId = convId,
role = "user", content = trimmed,
attachments = uploadedAtts, sortOrder = msgCount - 2,
),
MessageEntity(
id = assistantWireId, conversationId = convId,
role = "assistant", content = finalContent,
attachments = emptyList(), sortOrder = msgCount - 1,
),
))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
refreshConversations()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx >= 0) { if (idx >= 0) {
val existing = messages[idx].content val existing = messages[idx].content
val errorSuffix = "\n\n${chatErrorText(e)}" val errorSuffix = "\n\n${chatErrorText(e, context)}"
messages[idx] = messages[idx].copy( messages[idx] = messages[idx].copy(
streaming = false, streaming = false,
thinking = false, thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e), content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e, context),
) )
} }
} finally { } finally {
@ -345,19 +426,7 @@ fun ChatScreen(
} }
} }
// 2. Floating Top Menu Button (Apple Liquid Glass - completely independent of a header bar) // 2. Floating Top Menu Button
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)))
}
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
@ -365,22 +434,23 @@ fun ChatScreen(
.padding(start = 16.dp, top = 10.dp, end = 16.dp), .padding(start = 16.dp, top = 10.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level2,
cornerRadius = 22.dp,
modifier = Modifier modifier = Modifier
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds .size(44.dp)
.clip(CircleShape) .clickable { haptics.tick(); scope.launch { drawerState.open() } },
.background(menuGlassBg)
.border(1.2.dp, menuGlassBorder, CircleShape)
.clickable { haptics.tick(); scope.launch { drawerState.open() } }
.size(44.dp),
contentAlignment = Alignment.Center
) { ) {
Icon( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
imageVector = Icons.Rounded.Menu, Icon(
contentDescription = "Menü öffnen", imageVector = Icons.Rounded.Menu,
tint = MaterialTheme.colorScheme.onBackground, contentDescription = context.getString(R.string.chat_open_menu),
modifier = Modifier.size(20.dp) tint = MaterialTheme.colorScheme.onBackground,
) modifier = Modifier.size(20.dp)
)
}
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
ModelPill( ModelPill(
@ -397,9 +467,9 @@ fun ChatScreen(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.statusBarsPadding() .statusBarsPadding()
.padding(top = 62.dp, start = 24.dp, end = 24.dp) .padding(top = 62.dp, start = 24.dp, end = 24.dp)
.clip(RoundedCornerShape(12.dp)) .clip(KaizenShapes.sm)
.background(Color(0xFFDC2626).copy(alpha = 0.15f)) .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), .padding(horizontal = 14.dp, vertical = 8.dp),
) { ) {
Text( Text(
@ -425,6 +495,9 @@ fun ChatScreen(
onValueChange = { input = it }, onValueChange = { input = it },
onSend = { send(input) }, onSend = { send(input) },
enabled = !isStreaming, enabled = !isStreaming,
pendingFiles = pendingFiles,
onAddClick = { filePicker.launch("*/*") },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
) )
Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens
} }
@ -447,11 +520,10 @@ fun ChatScreen(
} }
} }
/** Maps a streaming failure to a short, human German message shown in the bubble. */ private fun chatErrorText(e: Throwable, ctx: android.content.Context): String = when {
private fun chatErrorText(e: Throwable): String = when { e is ChatHttpException && e.code == 401 -> ctx.getString(R.string.error_session_expired)
e is ChatHttpException && e.code == 401 -> "Sitzung abgelaufen. Bitte erneut anmelden." e is ChatHttpException && e.code == 402 -> ctx.getString(R.string.error_credits_exhausted)
e is ChatHttpException && e.code == 402 -> "Guthaben aufgebraucht." e is ChatHttpException && e.code == 429 -> ctx.getString(R.string.error_rate_limited)
e is ChatHttpException && e.code == 429 -> "Zu viele Anfragen. Kurz warten." e is ChatHttpException -> ctx.getString(R.string.error_server, e.code)
e is ChatHttpException -> "Fehler vom Server (${e.code})." else -> ctx.getString(R.string.error_no_connection)
else -> "Keine Verbindung zum Server."
} }

View file

@ -0,0 +1,12 @@
package dev.kaizen.app.chat
import android.content.Context
import dev.kaizen.app.db.ConversationRepository
import dev.kaizen.app.db.KaizenDatabase
import dev.kaizen.app.db.MessageRepository
class ChatViewModel(context: Context) {
private val db = KaizenDatabase.get(context)
val conversationRepo = ConversationRepository(db.conversationDao())
val messageRepo = MessageRepository(db.messageDao())
}

View file

@ -2,25 +2,40 @@ package dev.kaizen.app.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape 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
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
@ -28,8 +43,12 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.haptics.rememberHaptics
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private sealed interface MdBlock { private sealed interface MdBlock {
data class Paragraph(val text: String) : MdBlock data class Paragraph(val text: String) : MdBlock
@ -37,6 +56,8 @@ private sealed interface MdBlock {
data class CodeFence(val lang: String, val code: String) : MdBlock data class CodeFence(val lang: String, val code: String) : MdBlock
data class ListBlock(val items: List<ListItem>) : MdBlock data class ListBlock(val items: List<ListItem>) : MdBlock
data class Item(val text: String, val ordered: Boolean, val index: Int) : MdBlock data class Item(val text: String, val ordered: Boolean, val index: Int) : MdBlock
data object HorizontalRule : MdBlock
data class Blockquote(val text: String) : MdBlock
} }
private data class ListItem(val text: String, val ordered: Boolean, val index: Int) private data class ListItem(val text: String, val ordered: Boolean, val index: Int)
@ -48,6 +69,29 @@ private fun parseBlocks(src: String): List<MdBlock> {
while (i < lines.size) { while (i < lines.size) {
val line = lines[i] val line = lines[i]
// Horizontal rule: ---, ***, ___ (3+ of same char, optionally spaced)
if (Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(line)) {
blocks.add(MdBlock.HorizontalRule)
i++
continue
}
// Blockquote: > ...
val bqMatch = Regex("^\\s*>\\s?(.*)").matchEntire(line)
if (bqMatch != null) {
val bqLines = mutableListOf(bqMatch.groupValues[1])
i++
while (i < lines.size) {
val m = Regex("^\\s*>\\s?(.*)").matchEntire(lines[i])
if (m != null) {
bqLines.add(m.groupValues[1])
i++
} else break
}
blocks.add(MdBlock.Blockquote(bqLines.joinToString("\n")))
continue
}
// Code fence // Code fence
if (line.trimStart().startsWith("```")) { if (line.trimStart().startsWith("```")) {
val lang = line.trimStart().removePrefix("```").trim() val lang = line.trimStart().removePrefix("```").trim()
@ -117,6 +161,8 @@ private fun parseBlocks(src: String): List<MdBlock> {
|| Regex("^#{1,3}\\s+").containsMatchIn(next) || Regex("^#{1,3}\\s+").containsMatchIn(next)
|| Regex("^\\s*[-*+]\\s+").containsMatchIn(next) || Regex("^\\s*[-*+]\\s+").containsMatchIn(next)
|| Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next) || Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next)
|| Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(next)
|| Regex("^\\s*>\\s?").containsMatchIn(next)
) break ) break
paraLines.add(next) paraLines.add(next)
i++ i++
@ -126,11 +172,26 @@ private fun parseBlocks(src: String): List<MdBlock> {
return blocks return blocks
} }
private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, codeBg: Color): AnnotatedString { private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, codeBg: Color, linkColor: Color = Color(0xFF3B82F6)): AnnotatedString {
return buildAnnotatedString { return buildAnnotatedString {
var pos = 0 var pos = 0
val src = text val src = text
while (pos < src.length) { while (pos < src.length) {
// Link: [text](url)
if (src[pos] == '[') {
val closeBracket = src.indexOf(']', pos + 1)
if (closeBracket > pos && closeBracket + 1 < src.length && src[closeBracket + 1] == '(') {
val closeParen = src.indexOf(')', closeBracket + 2)
if (closeParen > closeBracket) {
val linkText = src.substring(pos + 1, closeBracket)
withStyle(SpanStyle(color = linkColor, fontWeight = FontWeight.Medium)) {
append(linkText)
}
pos = closeParen + 1
continue
}
}
}
// Inline code: `...` // Inline code: `...`
if (src[pos] == '`' && pos + 1 < src.length) { if (src[pos] == '`' && pos + 1 < src.length) {
val end = src.indexOf('`', pos + 1) val end = src.indexOf('`', pos + 1)
@ -324,20 +385,27 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
.border(1.dp, codeBorder, RoundedCornerShape(10.dp)) .border(1.dp, codeBorder, RoundedCornerShape(10.dp))
) { ) {
Column { Column {
if (block.lang.isNotBlank()) { // Header row: lang badge + copy button
Row(
Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 6.dp, top = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text( Text(
block.lang, if (block.lang.isNotBlank()) block.lang else "code",
color = langBadgeColor, color = langBadgeColor,
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
) )
Spacer(Modifier.weight(1f))
CopyButton(block.code)
} }
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = if (block.lang.isNotBlank()) 6.dp else 10.dp) .padding(horizontal = 12.dp, vertical = 6.dp)
) { ) {
Text( Text(
text = highlightCode(block.code + if (isLast) cursor else "", block.lang, isDark), text = highlightCode(block.code + if (isLast) cursor else "", block.lang, isDark),
@ -375,6 +443,45 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
is MdBlock.HorizontalRule -> {
if (idx > 0) Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(cs.onSurfaceVariant.copy(alpha = 0.25f))
)
Spacer(Modifier.height(8.dp))
}
is MdBlock.Blockquote -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
Row(
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(start = 2.dp)
) {
Box(
Modifier
.width(3.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(1.5.dp))
.background(cs.primary.copy(alpha = 0.5f))
)
Spacer(Modifier.width(10.dp))
Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg),
color = cs.onSurfaceVariant,
fontSize = 15.sp,
lineHeight = 22.sp,
fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(6.dp))
}
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(6.dp))
Text( Text(
@ -390,3 +497,39 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
} }
} }
@Composable
private fun CopyButton(code: String) {
val clipboard = LocalClipboard.current
val haptics = rememberHaptics()
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
val cs = MaterialTheme.colorScheme
Box(
Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable {
haptics.tick()
copied = true
scope.launch {
clipboard.setClipEntry(
androidx.compose.ui.platform.ClipEntry(
android.content.ClipData.newPlainText("code", code)
)
)
delay(1500)
copied = false
}
},
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,
contentDescription = "Kopieren",
tint = if (copied) Color(0xFF22C55E) else cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp),
)
}
}

View file

@ -6,8 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.* import androidx.compose.material.icons.rounded.*
@ -35,9 +34,9 @@ fun ModelPill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier)
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
Row( Row(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(22.dp)) .clip(KaizenShapes.xl)
.background(cs.surface.copy(alpha = 0.7f)) .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() } .clickable { onClick() }
.padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp), .padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -128,9 +127,9 @@ fun ModelSheet(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .clip(KaizenShapes.md)
.background(cs.surfaceVariant.copy(alpha = 0.4f)) .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), .padding(horizontal = 14.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -229,7 +228,7 @@ private fun ModelRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.clip(RoundedCornerShape(16.dp)) .clip(KaizenShapes.md)
.background( .background(
if (selected) { if (selected) {
Amber.copy(alpha = 0.08f) Amber.copy(alpha = 0.08f)
@ -240,7 +239,7 @@ private fun ModelRow(
.border( .border(
width = 1.dp, width = 1.dp,
color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent, color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent,
shape = RoundedCornerShape(16.dp) shape = KaizenShapes.md
) )
.clickable { haptics.tick(); onClick() } .clickable { haptics.tick(); onClick() }
.padding(horizontal = 14.dp, vertical = 12.dp), .padding(horizontal = 14.dp, vertical = 12.dp),
@ -249,7 +248,7 @@ private fun ModelRow(
// Dynamic Colored Badge for Provider (Vertex AI or OpenRouter) // Dynamic Colored Badge for Provider (Vertex AI or OpenRouter)
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp)) .clip(KaizenShapes.xs)
.background(providerInfo.color.copy(alpha = 0.15f)) .background(providerInfo.color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) { ) {
@ -284,7 +283,7 @@ private fun ModelRow(
Box( Box(
Modifier Modifier
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.clickable { haptics.tick(); onToggleFavorite() }, .clickable { haptics.tick(); onToggleFavorite() },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View file

@ -3,7 +3,6 @@ package dev.kaizen.app.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.Edit 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.Color
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary 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 dev.kaizen.app.ui.theme.Amber
import androidx.compose.ui.res.stringResource
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
/** Date-bucket header for a conversation, from its ISO `updatedAt` (Heute/Gestern/dd. MMM yyyy). */ private data class DateLabels(val today: String, val yesterday: String, val earlier: String, val pinned: String)
private fun dateLabel(updatedAt: String?): String {
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER" 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 date = instant.atZone(ZoneId.systemDefault()).toLocalDate()
val today = LocalDate.now() val today = LocalDate.now()
return when (date) { return when (date) {
today -> "HEUTE" today -> labels.today
today.minusDays(1) -> "GESTERN" today.minusDays(1) -> labels.yesterday
else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN)).uppercase() 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>, labels: DateLabels): Map<String, List<ConversationSummary>> {
private fun groupConversations(conversations: List<ConversationSummary>): Map<String, List<ConversationSummary>> {
val groups = LinkedHashMap<String, MutableList<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 { conversations.filterNot { it.pinned }.forEach {
groups.getOrPut(dateLabel(it.updatedAt)) { mutableListOf() }.add(it) groups.getOrPut(dateLabel(it.updatedAt, labels)) { mutableListOf() }.add(it)
} }
return groups 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 @Composable
fun KaizenSidebar( fun KaizenSidebar(
userName: String, userName: String,
@ -97,150 +91,91 @@ fun KaizenSidebar(
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
// 1. Thick, premium milky-glass background gradient GlassSurface(
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(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(300.dp) .width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp) // Standard signature (start, top, end, bottom) .padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
.shadow(elevation = 16.dp, shape = RoundedCornerShape(24.dp), clip = false) shape = KaizenShapes.lg,
.clip(RoundedCornerShape(24.dp)) tintAlpha = GlassTiers.sidebar(isDark),
.background(glassBg) shadowStack = KaizenShadows.level3,
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) cornerRadius = 24.dp,
.statusBarsPadding()
.padding(16.dp)
) { ) {
Column(Modifier.fillMaxSize()) { Column(
Modifier
// --- HEADER Sektion: Brand title and New Chat button --- .fillMaxSize()
.statusBarsPadding()
.padding(16.dp)
) {
// Header
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(stringResource(R.string.sidebar_brand), color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
text = "kaizen",
color = cs.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
// Translucent "New Chat" pencil button
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000)) .background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() }, .clickable { onNewChat() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(18.dp))
imageVector = Icons.Rounded.Edit,
contentDescription = "Neuer Chat",
tint = cs.onBackground,
modifier = Modifier.size(18.dp)
)
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// --- SEARCH Sektion: Glassmorphic search capsule --- // Search
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border( .border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
1.dp,
if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f),
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp))
imageVector = Icons.Rounded.Search,
contentDescription = null,
tint = cs.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp)
text = "Suchen...",
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 14.sp
)
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
// --- BODY Sektion: Scrollable Chats list grouped by Date --- // Conversation list
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) { if (conversations.isEmpty()) {
Text( Text(
text = "Noch keine Chats", stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.5f), color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp) modifier = Modifier.padding(start = 12.dp, top = 12.dp)
) )
} else { } 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
grouped.forEach { (dateGroup, items) -> grouped.forEach { (dateGroup, items) ->
// Date header (e.g., HEUTE, 16. JUN 2026)
item(key = "header_$dateGroup") { item(key = "header_$dateGroup") {
Text( Text(
text = dateGroup, dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f), color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
) )
} }
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
HistoryItemRow( HistoryItemRow(
item = item, item = item,
@ -255,81 +190,50 @@ fun KaizenSidebar(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// --- FOOTER Sektion: User Profile Card --- // User card
// Clicking the card opens the brand settings screen cleanly (MVVM state)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(18.dp)) .clip(KaizenShapes.md)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border( .border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
1.dp,
if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f),
RoundedCornerShape(18.dp)
)
.clickable { onOpenSettings() } .clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// User Avatar bubble: Obsidian colored dynamic first-letter initials!
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)), .background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = userName.firstOrNull()?.toString()?.uppercase() ?: "A", userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
fontSize = 17.sp,
fontWeight = FontWeight.Bold
) )
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) { 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(Icons.Rounded.Settings, stringResource(R.string.sidebar_settings), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = "Einstellungen",
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
// --- Logout: clears the encrypted token + returns to the login screen --- // Logout
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(14.dp)) .clip(KaizenShapes.sm)
.clickable { onLogout() } .clickable { onLogout() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(Icons.AutoMirrored.Rounded.Logout, stringResource(R.string.sidebar_logout), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
imageVector = Icons.AutoMirrored.Rounded.Logout,
contentDescription = "Abmelden",
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text( Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium)
text = "Abmelden",
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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(CircleShape) .clip(KaizenShapes.pill)
.background(itemBg) .background(itemBg)
.border(1.2.dp, itemBorder, CircleShape) .border(1.2.dp, itemBorder, KaizenShapes.pill)
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 11.dp), .padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item.pinned) { if (item.pinned) {
Icon( Icon(Icons.Rounded.Star, null, tint = Amber, modifier = Modifier.size(14.dp))
imageVector = Icons.Rounded.Star,
contentDescription = null,
tint = Amber,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text( 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, color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal, fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
if (item.locked) { if (item.locked) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Icon( Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = Amber, modifier = Modifier.size(15.dp))
imageVector = Icons.Rounded.Lock,
contentDescription = "Gesperrt",
tint = Amber,
modifier = Modifier.size(15.dp)
)
} }
} }
} }

View 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,
)
}
}

View file

@ -0,0 +1,28 @@
package dev.kaizen.app.db
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY pinned DESC, updatedAt DESC")
fun observeAll(): Flow<List<ConversationEntity>>
@Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
@Transaction
suspend fun replaceAll(conversations: List<ConversationEntity>) {
deleteAll()
upsertAll(conversations)
}
@Query("SELECT cachedAt FROM conversations LIMIT 1")
suspend fun lastCachedAt(): Long?
}

View file

@ -0,0 +1,25 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ConversationRepository(private val dao: ConversationDao) {
fun observeAll(): Flow<List<ConversationSummary>> =
dao.observeAll().map { list -> list.map { it.toSummary() } }
suspend fun refresh(baseUrl: String, token: String) {
when (val r = KaizenApi.fetchConversations(baseUrl, token)) {
is FetchResult.Ok -> {
val entities = r.data.map { it.toEntity() }
dao.replaceAll(entities)
}
is FetchResult.Fail -> { /* cache stays, no crash */ }
}
}
suspend fun lastCachedAt(): Long? = dao.lastCachedAt()
}

View file

@ -0,0 +1,18 @@
package dev.kaizen.app.db
import androidx.room.TypeConverter
import dev.kaizen.app.net.Attachment
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class Converters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun attachmentsToJson(attachments: List<Attachment>): String =
json.encodeToString(attachments)
@TypeConverter
fun jsonToAttachments(value: String): List<Attachment> =
try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
}

View file

@ -0,0 +1,36 @@
package dev.kaizen.app.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import dev.kaizen.app.net.Attachment
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val id: String,
val title: String,
val locked: Boolean,
val pinned: Boolean,
val updatedAt: String?,
val cachedAt: Long,
)
@Entity(
tableName = "messages",
foreignKeys = [ForeignKey(
entity = ConversationEntity::class,
parentColumns = ["id"],
childColumns = ["conversationId"],
onDelete = ForeignKey.CASCADE,
)],
indices = [Index("conversationId")],
)
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val role: String,
val content: String,
val attachments: List<Attachment>,
val sortOrder: Int,
)

View file

@ -0,0 +1,34 @@
package dev.kaizen.app.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [ConversationEntity::class, MessageEntity::class],
version = 1,
exportSchema = false,
)
@TypeConverters(Converters::class)
abstract class KaizenDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
companion object {
@Volatile private var instance: KaizenDatabase? = null
fun get(context: Context): KaizenDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
KaizenDatabase::class.java,
"kaizen-cache",
)
.fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
}
}
}

View file

@ -0,0 +1,37 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.StoredMessage
fun ConversationSummary.toEntity() = ConversationEntity(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
cachedAt = System.currentTimeMillis(),
)
fun ConversationEntity.toSummary() = ConversationSummary(
id = id,
title = title,
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
)
fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(
id = id,
conversationId = conversationId,
role = role,
content = content,
attachments = attachments,
sortOrder = sortOrder,
)
fun MessageEntity.toStoredMessage() = StoredMessage(
id = id,
role = role,
content = content,
attachments = attachments,
)

View file

@ -0,0 +1,21 @@
package dev.kaizen.app.db
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
fun observeByConversation(convId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
suspend fun getByConversation(convId: String): List<MessageEntity>
@Upsert
suspend fun upsertAll(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE conversationId = :convId")
suspend fun deleteByConversation(convId: String)
}

View file

@ -0,0 +1,24 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.KaizenApi
import kotlinx.coroutines.flow.Flow
class MessageRepository(private val dao: MessageDao) {
fun observeMessages(convId: String): Flow<List<MessageEntity>> =
dao.observeByConversation(convId)
suspend fun getCached(convId: String): List<MessageEntity> =
dao.getByConversation(convId)
suspend fun fetchAndCache(baseUrl: String, token: String, convId: String) {
val stored = KaizenApi.fetchMessages(baseUrl, token, convId)
val entities = stored.mapIndexed { i, m -> m.toEntity(convId, i) }
dao.deleteByConversation(convId)
dao.upsertAll(entities)
}
suspend fun cacheMessages(convId: String, messages: List<MessageEntity>) {
dao.upsertAll(messages)
}
}

View file

@ -10,6 +10,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@ -22,13 +23,18 @@ import java.util.concurrent.TimeUnit
@Serializable private data class TokenRequest(val email: String, val password: String, val name: String) @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 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 data class WireMessage(val role: String, val content: String)
@Serializable private data class ChatRequest( @Serializable private data class ChatRequest(
val model: String, val model: String,
val messages: List<WireMessage>, val messages: List<WireMessage>,
val reasoningEffort: String? = null, val reasoningEffort: String? = null,
val preferThroughput: Boolean? = null, val preferThroughput: Boolean? = null,
val attachments: List<Attachment>? = null,
) )
/** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */ /** One entry from GET /api/v1/models — the picker's row. `provider == "vertex"` drives the hoster badge. */
@ -55,12 +61,24 @@ data class ConversationSummary(
@Serializable private data class CreateConvoRequest(val title: String? = null) @Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @Serializable private data class CreatedConversation(val id: String)
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
@Serializable
data class Attachment(
val url: String,
val kind: String = "",
val name: String = "",
val mimeType: String = "",
val size: Long? = null,
val fileId: String? = null,
)
/** One persisted message from GET /api/v1/conversations/[id]. */ /** One persisted message from GET /api/v1/conversations/[id]. */
@Serializable @Serializable
data class StoredMessage( data class StoredMessage(
val id: String, val id: String,
val role: String, val role: String,
val content: String = "", val content: String = "",
val attachments: List<Attachment> = emptyList(),
) )
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList()) @Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@ -74,6 +92,7 @@ data class SaveMessage(
val content: String, val content: String,
val kind: String = "chat", val kind: String = "chat",
val model: String? = null, val model: String? = null,
val attachments: List<Attachment>? = null,
) )
@Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>) @Serializable private data class SaveMessagesRequest(val messages: List<SaveMessage>)
@ -123,7 +142,7 @@ object KaizenApi {
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream .readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
.build() .build()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val jsonMedia = "application/json".toMediaType() private val jsonMedia = "application/json".toMediaType()
/** POST /api/v1/auth/token — exchange email+password for a `kzn_` bearer token. */ /** POST /api/v1/auth/token — exchange email+password for a `kzn_` bearer token. */
@ -149,6 +168,22 @@ object KaizenApi {
} }
/** GET /api/v1/me — resolve the user's configured default model (best-effort, null on any failure). */ /** 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? = suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() val req = Request.Builder()
@ -314,13 +349,15 @@ object KaizenApi {
model: String, model: String,
messages: List<WireMessage>, messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal, speed: ChatSpeed = ChatSpeed.Normal,
): Flow<String> = flow { attachments: List<Attachment>? = null,
): Flow<StreamState> = flow {
val payload = json.encodeToString( val payload = json.encodeToString(
ChatRequest( ChatRequest(
model = model, model = model,
messages = messages, messages = messages,
reasoningEffort = speed.reasoningEffort, reasoningEffort = speed.reasoningEffort,
preferThroughput = if (speed.preferThroughput) true else null, preferThroughput = if (speed.preferThroughput) true else null,
attachments = attachments?.takeIf { it.isNotEmpty() },
), ),
).toRequestBody(jsonMedia) ).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()
@ -354,6 +391,46 @@ object KaizenApi {
* handshake (~150-300ms on mobile) from the first real request after app * handshake (~150-300ms on mobile) from the first real request after app
* start or background resume. * start or background resume.
*/ */
/** POST /api/v1/upload — multipart file upload, returns the created Attachment. */
suspend fun uploadFile(
baseUrl: String,
token: String,
fileName: String,
mimeType: String,
bytes: ByteArray,
): FetchResult<Attachment> = withContext(Dispatchers.IO) {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file", fileName,
bytes.toRequestBody(mimeType.toMediaType()),
)
.build()
val req = Request.Builder()
.url("$baseUrl/api/v1/upload")
.post(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
val reason = "upload: HTTP ${resp.code}" + when (resp.code) {
401 -> " (Token ungültig)"
413 -> " (Datei zu groß)"
else -> ""
}
Log.w(TAG, "uploadFile: HTTP ${resp.code}")
return@use FetchResult.Fail(reason)
}
val raw = resp.body!!.string()
FetchResult.Ok(json.decodeFromString<Attachment>(raw))
}
} catch (e: Exception) {
Log.w(TAG, "uploadFile: ${e.javaClass.simpleName}: ${e.message}")
FetchResult.Fail(diagnoseFetchError("upload", e))
}
}
suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) { suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
try { try {
val req = Request.Builder().url("$baseUrl/api/v1/meta").build() val req = Request.Builder().url("$baseUrl/api/v1/meta").build()

View file

@ -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() { fun clear() {
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
@ -89,5 +103,9 @@ class SecureStore(context: Context) {
const val KEY_SPEED = "speed" const val KEY_SPEED = "speed"
const val KEY_FAVORITES = "favorite_models" const val KEY_FAVORITES = "favorite_models"
const val KEY_MODELS_CACHE = "models_cache" 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"
} }
} }

View file

@ -1,54 +1,54 @@
package dev.kaizen.app.net package dev.kaizen.app.net
/** import kotlinx.serialization.Serializable
* Sentinel code points delimiting control sections in the `/api/v1/chat` stream. import kotlinx.serialization.json.Json
* 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 USAGE = 0.toChar() // private val SOURCES = 2.toChar()
private val REASONING = 1.toChar() // private val TOOL = 3.toChar()
private val SOURCES = 2.toChar() // private val QUERY = 4.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 { 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 { fun visibleText(raw: String): String {
val rest = stripLeadingToolEvents(raw) ?: return "" val rest = stripLeadingToolEvents(raw) ?: return ""
val segments = rest.split(REASONING) val segments = rest.split(REASONING)
val sb = StringBuilder() val sb = StringBuilder()
for (i in segments.indices) { for (i in segments.indices) {
if (i % 2 == 0) sb.append(segments[i]) if (i % 2 == 0) sb.append(segments[i])
} }
var text = sb.toString() var text = sb.toString()
val cut = listOf(text.indexOf(QUERY), text.indexOf(SOURCES), text.indexOf(USAGE)) val cut = listOf(text.indexOf(QUERY), text.indexOf(SOURCES), text.indexOf(USAGE))
.filter { it != -1 } .filter { it != -1 }.minOrNull()
.minOrNull()
if (cut != null) text = text.substring(0, cut) if (cut != null) text = text.substring(0, cut)
return text return text
} }
@ -56,38 +56,33 @@ object StreamConsumer {
var rest = raw var rest = raw
while (rest.isNotEmpty() && rest[0] == TOOL) { while (rest.isNotEmpty() && rest[0] == TOOL) {
val nl = rest.indexOf('\n') 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) rest = rest.substring(nl + 1)
} }
return rest 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().
*
* 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 { class Incremental {
private val content = StringBuilder(256) private val content = StringBuilder(256)
private val reasoningBuf = StringBuilder(256)
private val toolBuffer = StringBuilder() private val toolBuffer = StringBuilder()
private val trailingBuf = StringBuilder()
private val tools = mutableListOf<ToolEvent>()
private var pastToolEvents = false private var pastToolEvents = false
private var inReasoning = false private var inReasoning = false
private var inTrailing = false
private var done = false private var done = false
private var trailingSentinel: Char? = null
/** fun append(chars: CharArray, offset: Int, length: Int): StreamState {
* Feed a chunk of decoded characters. Returns the current visible text. if (done) return buildState()
* 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()
if (!pastToolEvents) { if (!pastToolEvents) {
toolBuffer.append(chars, offset, length) toolBuffer.append(chars, offset, length)
if (toolBuffer.isEmpty()) return buildState()
val end = findToolEventsEnd(toolBuffer) val end = findToolEventsEnd(toolBuffer)
if (end == -1) return "" if (end == -1) return buildState()
parseToolEvents(toolBuffer, 0, end)
pastToolEvents = true pastToolEvents = true
processRange(toolBuffer, end, toolBuffer.length) processRange(toolBuffer, end, toolBuffer.length)
toolBuffer.setLength(0) toolBuffer.setLength(0)
@ -96,11 +91,63 @@ object StreamConsumer {
processChars(chars, offset, length) processChars(chars, offset, length)
} }
return content.toString() return buildState()
} }
/** Convenience overload for string input (tests). */ fun append(chunk: String): StreamState = append(chunk.toCharArray(), 0, chunk.length)
fun append(chunk: String): String = 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) { private fun processRange(buf: CharSequence, from: Int, to: Int) {
for (i in from until to) { for (i in from until to) {
@ -118,18 +165,24 @@ object StreamConsumer {
} }
private fun processChar(c: Char) { private fun processChar(c: Char) {
when (c) { when {
REASONING -> inReasoning = !inReasoning inTrailing -> trailingBuf.append(c)
USAGE, SOURCES, QUERY -> done = true c == REASONING -> inReasoning = !inReasoning
else -> if (!inReasoning) content.append(c) c == QUERY || c == SOURCES || c == USAGE -> {
inTrailing = true
trailingBuf.append(c)
}
inReasoning -> reasoningBuf.append(c)
else -> content.append(c)
} }
} }
companion object { companion object {
private fun findToolEventsEnd(buf: StringBuilder): Int { private fun findToolEventsEnd(buf: StringBuilder): Int {
var pos = 0 var pos = 0
while (pos < buf.length && buf[pos] == TOOL) { val s = buf.toString()
val nl = buf.indexOf("\n", pos) while (pos < s.length && s[pos] == TOOL) {
val nl = s.indexOf('\n', pos)
if (nl == -1) return -1 if (nl == -1) return -1
pos = nl + 1 pos = nl + 1
} }

View file

@ -25,8 +25,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import dev.kaizen.app.ui.effect.GlassSurface
import androidx.compose.foundation.shape.RoundedCornerShape 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.text.BasicTextField
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Amber
import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.Oklab 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 */ /** 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 { private fun oklch(l: Float, c: Float, h: Float, alpha: Float = 1.0f): Color {
@ -81,53 +84,14 @@ fun SettingsCard(
) { ) {
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val glassBg = remember(isDark) { GlassSurface(
if (isDark) { modifier = modifier.fillMaxWidth(),
Brush.verticalGradient( shape = KaizenShapes.lg,
listOf( tintAlpha = GlassTiers.card(isDark),
Color(0xFF1E293B).copy(alpha = 0.55f), // Slate 800 (55% opacity) shadowStack = KaizenShadows.level2,
Color(0xFF0F172A).copy(alpha = 0.65f) // Slate 900 (65% opacity) cornerRadius = 24.dp,
)
)
} 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)
) { ) {
// Standard Column provides standard ColumnScope receiver to the child layout content slot Column(modifier = Modifier.fillMaxWidth().padding(18.dp)) {
Column(modifier = Modifier.fillMaxWidth()) {
content() content()
} }
} }
@ -152,7 +116,7 @@ fun SettingsOptionRow(
Box( Box(
modifier = Modifier modifier = Modifier
.size(38.dp) .size(38.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background(cs.onBackground.copy(alpha = 0.06f)), .background(cs.onBackground.copy(alpha = 0.06f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -203,9 +167,9 @@ fun SettingsInputCapsule(
Box( Box(
modifier = modifier modifier = modifier
.widthIn(max = 180.dp) .widthIn(max = 180.dp)
.clip(RoundedCornerShape(12.dp)) .clip(KaizenShapes.sm)
.background(inputGlassBg) .background(inputGlassBg)
.border(1.dp, inputGlassBorder, RoundedCornerShape(12.dp)) .border(1.dp, inputGlassBorder, KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
@ -238,17 +202,17 @@ fun ThemeOptionPill(
val (colorStart, colorEnd) = remember(themeId, isDark) { val (colorStart, colorEnd) = remember(themeId, isDark) {
if (isDark) { if (isDark) {
when (themeId) { when (themeId) {
KaizenThemeId.Amber -> oklch(0.76f, 0.14f, 83f) to oklch(0.55f, 0.16f, 83f) 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.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.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.MONO -> oklch(0.92f, 0f, 255f) to oklch(0.35f, 0f, 255f)
} }
} else { } else {
when (themeId) { when (themeId) {
KaizenThemeId.Amber -> oklch(0.72f, 0.14f, 83f) to oklch(0.85f, 0.15f, 83f) 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.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.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.MONO -> oklch(0.22f, 0f, 255f) to oklch(0.85f, 0f, 255f)
} }
} }
} }
@ -262,8 +226,8 @@ fun ThemeOptionPill(
Box( Box(
modifier = modifier modifier = modifier
.size(48.dp) .size(48.dp)
.shadow(elevation = 4.dp, shape = CircleShape, clip = false) .shadow(elevation = 4.dp, shape = KaizenShapes.circle, clip = false)
.clip(CircleShape) .clip(KaizenShapes.circle)
.clickable { onClick() } .clickable { onClick() }
.border( .border(
width = if (selected) 2.5.dp else 1.2.dp, width = if (selected) 2.5.dp else 1.2.dp,
@ -272,10 +236,10 @@ fun ThemeOptionPill(
} else { } else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.35f), Color.White.copy(alpha = 0.05f))) 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 .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 // Draw the perception gradient on Canvas
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
@ -288,7 +252,7 @@ fun ThemeOptionPill(
Icon( Icon(
imageVector = Icons.Rounded.Check, imageVector = Icons.Rounded.Check,
contentDescription = null, 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) modifier = Modifier.size(16.dp)
) )
} }
@ -317,8 +281,8 @@ fun AppearanceTogglePill(
Box( Box(
modifier = modifier modifier = modifier
.shadow(elevation = if (selected) 4.dp else 0.dp, shape = CircleShape, clip = false) .shadow(elevation = if (selected) 4.dp else 0.dp, shape = KaizenShapes.circle, clip = false)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background( .background(
if (selected) { if (selected) {
Amber.copy(alpha = 0.16f) Amber.copy(alpha = 0.16f)
@ -333,7 +297,7 @@ fun AppearanceTogglePill(
} else { } else {
glassBorder glassBorder
}, },
shape = CircleShape shape = KaizenShapes.circle
) )
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 9.dp), .padding(horizontal = 16.dp, vertical = 9.dp),

View file

@ -17,11 +17,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.AutoAwesome
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Settings 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Brush import dev.kaizen.app.R
import androidx.compose.ui.graphics.Color import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -63,21 +67,7 @@ fun SettingsScreen(
val appearanceMode by viewModel.appearanceMode.collectAsState() val appearanceMode by viewModel.appearanceMode.collectAsState()
val userName by viewModel.userName.collectAsState() val userName by viewModel.userName.collectAsState()
val userEmail by viewModel.userEmail.collectAsState() val userEmail by viewModel.userEmail.collectAsState()
val locale by viewModel.locale.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)))
}
}
MeshBackground { MeshBackground {
Column( Column(
@ -95,139 +85,70 @@ fun SettingsScreen(
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( GlassSurface(
modifier = Modifier shape = KaizenShapes.circle,
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) tintAlpha = GlassTiers.pill(isDark),
.clip(CircleShape) shadowStack = KaizenShadows.level2,
.background(backBtnGlassBg) cornerRadius = 22.dp,
.border(1.2.dp, backBtnGlassBorder, CircleShape) modifier = Modifier.size(44.dp).clickable { onBack() },
.clickable { onBack() }
.size(44.dp),
contentAlignment = Alignment.Center
) { ) {
Icon( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
imageVector = Icons.Rounded.ArrowBack, Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Zurück", tint = cs.onBackground, modifier = Modifier.size(20.dp))
contentDescription = "Zurück", }
tint = cs.onBackground,
modifier = Modifier.size(20.dp)
)
} }
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Text( Text(stringResource(R.string.settings_title), color = cs.onBackground, fontSize = 24.sp, fontWeight = FontWeight.Bold)
text = "Einstellungen",
color = cs.onBackground,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
} }
// --- SEKTION 1: Erscheinungsbild (Theme & Appearance) --- 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))
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)
)
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) { SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
// Theme Picker Row (Oklab perception space stops) SettingsOptionRow(title = stringResource(R.string.settings_accent_title), description = stringResource(R.string.settings_accent_desc), icon = Icons.Rounded.Palette) {
SettingsOptionRow( Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
title = "Akzentfarbe", KaizenThemeId.entries.forEach { theme ->
description = "Wähle das Farbschema der App (Oklab perzeptiv)", ThemeOptionPill(themeId = theme, selected = selectedTheme == theme, onClick = { viewModel.selectTheme(theme) })
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) }
)
} }
} }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
SettingsOptionRow(title = stringResource(R.string.settings_appearance_title), description = stringResource(R.string.settings_appearance_desc), icon = Icons.Rounded.Settings) {
// Light / Dark / System Appearance segmented toggle pills Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
SettingsOptionRow( AppAppearance.entries.forEach { mode ->
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 ->
val label = when (mode) { val label = when (mode) {
AppAppearance.Light -> "Hell" AppAppearance.Light -> stringResource(R.string.settings_appearance_light)
AppAppearance.Dark -> "Dunkel" AppAppearance.Dark -> stringResource(R.string.settings_appearance_dark)
AppAppearance.System -> "System" AppAppearance.System -> stringResource(R.string.settings_appearance_system)
} }
AppearanceTogglePill( AppearanceTogglePill(selected = appearanceMode == mode, label = label, onClick = { viewModel.selectAppearance(mode) })
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(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))
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)
)
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) { SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
// Username editor row SettingsOptionRow(title = stringResource(R.string.settings_name_title), description = stringResource(R.string.settings_name_desc), icon = Icons.Rounded.Person) {
SettingsOptionRow( SettingsInputCapsule(value = userName, onValueChange = { viewModel.updateUserName(it) })
title = "Benutzername",
description = "Ändere deinen Anzeigenamen",
icon = Icons.Rounded.Person
) {
SettingsInputCapsule(
value = userName,
onValueChange = { viewModel.updateUserName(it) }
)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
SettingsOptionRow(title = stringResource(R.string.settings_email_title), description = userEmail, icon = Icons.Rounded.Settings)
// Email display row (read-only in this screen view)
SettingsOptionRow(
title = "E-Mail-Adresse",
description = userEmail,
icon = Icons.Rounded.Settings
)
} }
// --- SEKTION 3: KI-Profil & Kontext --- 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))
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)
)
SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) { SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) {
SettingsOptionRow( SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome)
title = "System-Prompt & Fakten",
description = "Aktive Regeln: 12, Pinned: 3, Verwendetes Budget: 412 / 1500 Tokens",
icon = Icons.Rounded.AutoAwesome
)
} }
} }
} }

View file

@ -1,67 +1,58 @@
package dev.kaizen.app.settings package dev.kaizen.app.settings
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 } enum class AppAppearance { Light, Dark, System }
/** class SettingsViewModel(private val store: SecureStore? = null) : ViewModel() {
* 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() {
// 1. Reactive State Flow for Theme private val _selectedTheme = MutableStateFlow(
private val _selectedTheme = MutableStateFlow(KaizenThemeId.Amber) store?.loadThemeId()?.let { KaizenThemeId.fromString(it) } ?: KaizenThemeId.AMBER
)
val selectedTheme: StateFlow<KaizenThemeId> = _selectedTheme.asStateFlow() val selectedTheme: StateFlow<KaizenThemeId> = _selectedTheme.asStateFlow()
// 2. Reactive State Flow for Appearance private val _appearanceMode = MutableStateFlow(
private val _appearanceMode = MutableStateFlow(AppAppearance.Dark) store?.loadAppearance()?.let { runCatching { AppAppearance.valueOf(it) }.getOrNull() } ?: AppAppearance.System
)
val appearanceMode: StateFlow<AppAppearance> = _appearanceMode.asStateFlow() val appearanceMode: StateFlow<AppAppearance> = _appearanceMode.asStateFlow()
// 3. User profile details private val _userName = MutableStateFlow(store?.loadUserName() ?: "")
private val _userName = MutableStateFlow("Bruno")
val userName: StateFlow<String> = _userName.asStateFlow() val userName: StateFlow<String> = _userName.asStateFlow()
private val _userEmail = MutableStateFlow("admin@kryptomrx.de") private val _userEmail = MutableStateFlow("")
val userEmail: StateFlow<String> = _userEmail.asStateFlow() val userEmail: StateFlow<String> = _userEmail.asStateFlow()
// 4. Default Chat Model private val _locale = MutableStateFlow(store?.loadLocale() ?: "system")
private val _defaultModel = MutableStateFlow("gemini-2.5-flash") val locale: StateFlow<String> = _locale.asStateFlow()
val defaultModel: StateFlow<String> = _defaultModel.asStateFlow()
/** Selects a new Kaizen UI theme and triggers an Oklab transition stops recalculation */
fun selectTheme(themeId: KaizenThemeId) { fun selectTheme(themeId: KaizenThemeId) {
_selectedTheme.value = themeId _selectedTheme.value = themeId
store?.saveThemeId(themeId.value)
} }
/** Changes the appearance mode (Light, Dark, or System Sync) */
fun selectAppearance(mode: AppAppearance) { fun selectAppearance(mode: AppAppearance) {
_appearanceMode.value = mode _appearanceMode.value = mode
store?.saveAppearance(mode.name)
} }
/** Updates the user's name in profile settings */
fun updateUserName(name: String) { fun updateUserName(name: String) {
if (name.isNotBlank()) { if (name.isNotBlank()) {
_userName.value = name.trim() _userName.value = name.trim()
store?.saveUserName(name.trim())
} }
} }
/** Updates the standard chat model */ fun updateUserEmail(email: String) {
fun updateDefaultModel(modelId: String) { _userEmail.value = email
if (modelId.isNotBlank()) { }
_defaultModel.value = modelId.trim()
} fun selectLocale(locale: String) {
_locale.value = locale
store?.saveLocale(locale)
} }
} }

View 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
}

View 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 35 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(),
)
}
}

View 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
}

View 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
}

View 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
}

View 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 = 45 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()
}

View file

@ -1,32 +1,47 @@
package dev.kaizen.app.ui.theme package dev.kaizen.app.ui.theme
import androidx.compose.ui.graphics.Color 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). private fun p3(r: Float, g: Float, b: Float, a: Float = 1f): Color =
val Amber = Color(0xFFE0A23E) // ~oklch(0.72 0.14 83) Color(red = r, green = g, blue = b, alpha = a, colorSpace = ColorSpaces.DisplayP3)
val AmberDeep = Color(0xFFC8862A)
val OnAmber = Color(0xFF1B1206)
// Dark scheme (Obsidian) // ── Brand Amber (constant across all themes — they ARE the brand) ──────────
val Obsidian = Color(0xFF0A0E16) // background ~oklch(0.10 0.025 245) val Amber = p3(0.878f, 0.635f, 0.243f) // ≈ #E0A23E
val ObsidianElevated = Color(0xFF141B27) // surface val AmberDeep = p3(0.784f, 0.525f, 0.165f) // ≈ #C8862A
val ObsidianInput = Color(0xFF111824) // input surface val OnAmber = p3(0.106f, 0.071f, 0.024f) // ≈ #1B1206
val DarkText = Color(0xFFE8EBF1)
val DarkMuted = Color(0xFF8B94A4)
val DarkStroke = Color(0x16FFFFFF) // subtle light hairline
// Light scheme // ── Dark scheme — Obsidian Steel-Blue ──────────────────────────────────────
val LightBackground = Color(0xFFF6F7F9) val Obsidian = p3(0.039f, 0.055f, 0.086f) // background ≈ #0A0E16
val LightSurface = Color(0xFFFFFFFF) val ObsidianElevated = p3(0.078f, 0.106f, 0.153f) // surface ≈ #141B27
val LightSurfaceVariant = Color(0xFFEEF0F3) val ObsidianInput = p3(0.067f, 0.094f, 0.141f) // surfaceVar ≈ #111824
val LightText = Color(0xFF1A1F29) val DarkText = p3(0.910f, 0.922f, 0.945f) // onBackground ≈ #E8EBF1
val LightMuted = Color(0xFF5B6470) val DarkMuted = p3(0.545f, 0.580f, 0.643f) // onSurfaceVar ≈ #8B94A4
val LightStroke = Color(0x14000000) // subtle dark hairline val DarkStroke = p3(1f, 1f, 1f, 0.09f) // outline
// Background blobs (.bg-blobs) // ── Light scheme ───────────────────────────────────────────────────────────
val BlobAmber = Color(0xFFE0A23E) val LightBackground = p3(0.965f, 0.969f, 0.976f) // ≈ #F6F7F9
val BlobBlue = Color(0xFF3C5FD6) val LightSurface = p3(1.000f, 1.000f, 1.000f) // ≈ #FFFFFF
val BlobTeal = Color(0xFF2BB6A6) 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

View file

@ -2,110 +2,86 @@ package dev.kaizen.app.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces 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 { object Oklab {
private fun toLinear(c: Float): Float { private fun toLinear(c: Double): Double =
return if (c <= 0.04045f) c / 12.92f else Math.pow(((c + 0.055) / 1.055), 2.4).toFloat() if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
private fun toSRGB(c: Float): Float { private fun fromLinear(c: Double): Float {
val cl = c.coerceIn(0f, 1f) val cl = c.coerceIn(0.0, 1.0)
return if (cl <= 0.0031308f) cl * 12.92f else (1.055f * Math.pow(cl.toDouble(), 1.0 / 2.4) - 0.055f).toFloat() 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) 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 { fun Color.toOklab(): Lab {
// Convert to linear RGB val lr = toLinear(red.toDouble())
val lr = toLinear(red) val lg = toLinear(green.toDouble())
val lg = toLinear(green) val lb = toLinear(blue.toDouble())
val lb = toLinear(blue)
// Linear RGB to LMS val l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb
val l = 0.4122214708f * lr + 0.5363325363f * lg + 0.0514459929f * lb val m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb
val m = 0.1167116119f * lr + 0.6858151024f * lg + 0.1974732856f * lb val s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb
val s = 0.0883315616f * lr + 0.1117281988f * lg + 0.7999602417f * lb
// LMS to non-linear scaled LMS val lc = cbrt(l)
val l_ = Math.cbrt(l.toDouble()).toFloat() val mc = cbrt(m)
val m_ = Math.cbrt(m.toDouble()).toFloat() val sc = cbrt(s)
val s_ = Math.cbrt(s.toDouble()).toFloat()
// Scaled LMS to Oklab (L, a, b) return Lab(
val oklabL = 0.2104542553f * l_ + 0.7936177850f * m_ - 0.0040720468f * s_ l = (0.2104542553 * lc + 0.7936177850 * mc - 0.0040720468 * sc).toFloat(),
val oklabA = 1.9779984951f * l_ - 2.4285922050f * m_ + 0.4505937099f * s_ a = (1.9779984951 * lc - 2.4285922050 * mc + 0.4505937099 * sc).toFloat(),
val oklabB = 0.0259040371f * l_ + 0.7827717662f * m_ - 0.8086757660f * s_ b = (0.0259040371 * lc + 0.7827717662 * mc - 0.8086757660 * sc).toFloat(),
)
return Lab(oklabL, oklabA, oklabB)
} }
/** Converts Oklab coordinates back to standard Compose Color in DisplayP3 or sRGB space */ fun Lab.toColor(): Color {
fun Lab.toColor(targetColorSpace: androidx.compose.ui.graphics.colorspace.ColorSpace = ColorSpaces.Srgb): Color { val ld = l.toDouble()
// Oklab to scaled LMS val ad = a.toDouble()
val l_ = l + 0.3963377774f * a + 0.2158037573f * b val bd = b.toDouble()
val m_ = l - 0.1055613458f * a - 0.0638541728f * b
val s_ = l - 0.0894841775f * a - 1.2914855480f * b
// Scaled LMS to LMS (cube) val lp = ld + 0.3963377774 * ad + 0.2158037573 * bd
val l = l_ * l_ * l_ val mp = ld - 0.1055613458 * ad - 0.0638541728 * bd
val m = m_ * m_ * m_ val sp = ld - 0.0894841775 * ad - 1.2914855480 * bd
val s = s_ * s_ * s_
// LMS to linear RGB val lc = lp * lp * lp
val lr = 4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s val mc = mp * mp * mp
val lg = -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s val sc = sp * sp * sp
val lb = -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s
// Linear RGB to non-linear standard sRGB val r = +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc
val r = toSRGB(lr) val g = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc
val g = toSRGB(lg) val b = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc
val b = toSRGB(lb)
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 { fun interpolate(from: Color, to: Color, fraction: Float): Color {
val oklabFrom = from.toOklab() val f = from.toOklab()
val oklabTo = to.toOklab() val t = to.toOklab()
val interpolatedL = oklabFrom.l + (oklabTo.l - oklabFrom.l) * fraction val lab = Lab(
val interpolatedA = oklabFrom.a + (oklabTo.a - oklabFrom.a) * fraction l = f.l + (t.l - f.l) * fraction,
val interpolatedB = oklabFrom.b + (oklabTo.b - oklabFrom.b) * 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 val useP3 = from.colorSpace == ColorSpaces.DisplayP3 || to.colorSpace == ColorSpaces.DisplayP3
val color = if (useP3) lab.toColor(ColorSpaces.DisplayP3) else lab.toColor()
// Match the target color space (prefer DisplayP3 if either color is P3) return color.copy(alpha = from.alpha + (to.alpha - from.alpha) * fraction)
val targetSpace = if (from.colorSpace == ColorSpaces.DisplayP3 || to.colorSpace == ColorSpaces.DisplayP3) {
ColorSpaces.DisplayP3
} else {
ColorSpaces.Srgb
}
return Lab(interpolatedL, interpolatedA, interpolatedB).toColor(targetSpace).copy(alpha = interpolatedAlpha)
} }
/**
* 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> { fun generateGradientStops(from: Color, to: Color, steps: Int = 12): List<Color> {
val list = ArrayList<Color>(steps) return List(steps) { i ->
for (i in 0 until steps) { interpolate(from, to, i.toFloat() / (steps - 1).toFloat())
val fraction = i.toFloat() / (steps - 1).toFloat()
list.add(interpolate(from, to, fraction))
} }
return list
} }
} }

View file

@ -5,14 +5,29 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color 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( * CompositionLocal for the active Kaizen accent scheme.
primary = Amber, *
onPrimary = OnAmber, * Use `LocalKaizenAccent.current` to access theme-specific colors (primary,
secondary = Amber, * blobs, ring) that go beyond MaterialTheme.colorScheme. This is the bridge
onSecondary = OnAmber, * 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, background = Obsidian,
onBackground = DarkText, onBackground = DarkText,
surface = ObsidianElevated, surface = ObsidianElevated,
@ -22,11 +37,11 @@ private val KaizenDark = darkColorScheme(
outline = DarkStroke, outline = DarkStroke,
) )
private val KaizenLight = lightColorScheme( private fun buildLightScheme(accent: KaizenAccentScheme) = lightColorScheme(
primary = AmberDeep, primary = accent.primary,
onPrimary = Color.White, onPrimary = accent.onPrimary,
secondary = AmberDeep, secondary = accent.primary,
onSecondary = Color.White, onSecondary = accent.onPrimary,
background = LightBackground, background = LightBackground,
onBackground = LightText, onBackground = LightText,
surface = LightSurface, surface = LightSurface,
@ -38,12 +53,19 @@ private val KaizenLight = lightColorScheme(
@Composable @Composable
fun KaizenTheme( fun KaizenTheme(
themeId: KaizenThemeId = KaizenThemeId.AMBER,
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
MaterialTheme( val spec = ThemeRegistry.forId(themeId)
colorScheme = if (darkTheme) KaizenDark else KaizenLight, val accent = if (darkTheme) spec.dark else spec.light
typography = Typography, val colorScheme = if (darkTheme) buildDarkScheme(accent) else buildLightScheme(accent)
content = content,
) CompositionLocalProvider(LocalKaizenAccent provides accent) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
} }

View file

@ -2,33 +2,123 @@ package dev.kaizen.app.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle 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.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp 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( 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( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = InterVariable,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.W400,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.08.sp,
) ),
/* Other default text styles to override bodyMedium = TextStyle(
titleLarge = TextStyle( fontFamily = InterVariable,
fontFamily = FontFamily.Default, fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal, fontSize = 14.sp,
fontSize = 22.sp, lineHeight = 20.sp,
lineHeight = 28.sp, letterSpacing = 0.11.sp,
letterSpacing = 0.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( labelSmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = InterVariable,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.W500,
fontSize = 11.sp, fontSize = 11.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.5.sp letterSpacing = 0.33.sp,
) ),
*/ )
)

View file

@ -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,
)

View file

@ -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
),
)

View file

@ -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
),
)

View file

@ -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
),
)

View file

@ -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
),
)

View file

@ -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
}

View 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>

View file

@ -1,170 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <!-- Light mode: warm off-white with subtle amber tint -->
android:fillColor="#3DDC84" <path android:pathData="M0,0h108v108h-108z">
android:pathData="M0,0h108v108h-108z" /> <aapt:attr name="android:fillColor">
<path <gradient
android:fillColor="#00000000" android:type="radial"
android:pathData="M9,0L9,108" android:centerX="54"
android:strokeWidth="0.8" android:centerY="54"
android:strokeColor="#33FFFFFF" /> android:gradientRadius="76">
<path <item android:color="#FFF8F4EE" android:offset="0.0" />
android:fillColor="#00000000" <item android:color="#FFF1EBE3" android:offset="1.0" />
android:pathData="M19,0L19,108" </gradient>
android:strokeWidth="0.8" </aapt:attr>
android:strokeColor="#33FFFFFF" /> </path>
<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" />
</vector> </vector>

View file

@ -1,30 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt" xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="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"> <aapt:attr name="android:fillColor">
<gradient <gradient
android:endX="85.84757" android:type="radial"
android:endY="92.4963" android:centerX="54"
android:startX="42.9492" android:centerY="54"
android:startY="49.59793" android:gradientRadius="30">
android:type="linear"> <item android:color="#50E0A23E" android:offset="0.0" />
<item <item android:color="#18E0A23E" android:offset="0.6" />
android:color="#44000000" <item android:color="#00000000" android:offset="1.0" />
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient> </gradient>
</aapt:attr> </aapt:attr>
</path> </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 <path
android:fillColor="#FFFFFF" 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:fillType="nonZero" android:strokeWidth="0.6"
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:strokeColor="#25FFFFFF"
android:strokeWidth="1" android:fillColor="#00000000" />
android:strokeColor="#00000000" /> </vector>
</vector>

View 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>

Binary file not shown.

View file

@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon> </adaptive-icon>

View file

@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View 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 &amp; ACCOUNT</string>
<string name="settings_section_ai">AI PROFILE &amp; 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 &amp; 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>

View file

@ -1,3 +1,105 @@
<resources> <resources>
<string name="app_name">Kaizen</string> <string name="app_name">Kaizen</string>
</resources>
<!-- 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 &amp; KONTO</string>
<string name="settings_section_ai">KI-PROFIL &amp; 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 &amp; 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>

View file

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Kaizen" parent="android:Theme.Material.NoActionBar"> <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:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@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> </style>
</resources> </resources>

View 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)
}
}

View file

@ -0,0 +1,64 @@
package dev.kaizen.app
import dev.kaizen.app.db.Converters
import dev.kaizen.app.net.Attachment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConvertersTest {
private val converters = Converters()
@Test
fun emptyListRoundTrip() {
val json = converters.attachmentsToJson(emptyList())
val result = converters.jsonToAttachments(json)
assertTrue(result.isEmpty())
}
@Test
fun singleAttachmentRoundTrip() {
val original = listOf(
Attachment(url = "https://cdn.kaizen.dev/img.png", kind = "image", name = "img.png", mimeType = "image/png", size = 2048)
)
val json = converters.attachmentsToJson(original)
val result = converters.jsonToAttachments(json)
assertEquals(1, result.size)
assertEquals("https://cdn.kaizen.dev/img.png", result[0].url)
assertEquals("image", result[0].kind)
assertEquals("img.png", result[0].name)
assertEquals("image/png", result[0].mimeType)
assertEquals(2048L, result[0].size)
}
@Test
fun multipleAttachmentsRoundTrip() {
val original = listOf(
Attachment(url = "https://a.com/1.pdf", kind = "document", name = "1.pdf"),
Attachment(url = "https://a.com/2.mp4", kind = "video", name = "2.mp4", size = 999999),
)
val json = converters.attachmentsToJson(original)
val result = converters.jsonToAttachments(json)
assertEquals(2, result.size)
assertEquals("1.pdf", result[0].name)
assertEquals("2.mp4", result[1].name)
assertEquals(999999L, result[1].size)
}
@Test
fun malformedJsonReturnsEmptyList() {
val result = converters.jsonToAttachments("not valid json")
assertTrue(result.isEmpty())
}
@Test
fun unknownFieldsAreIgnored() {
val json = """[{"url":"https://x.com/f","kind":"image","name":"f","mimeType":"","unknownField":42}]"""
val result = converters.jsonToAttachments(json)
assertEquals(1, result.size)
assertEquals("https://x.com/f", result[0].url)
}
}

View file

@ -0,0 +1,122 @@
package dev.kaizen.app
import dev.kaizen.app.db.ConversationEntity
import dev.kaizen.app.db.MessageEntity
import dev.kaizen.app.db.toEntity
import dev.kaizen.app.db.toStoredMessage
import dev.kaizen.app.db.toSummary
import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.StoredMessage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MappersTest {
@Test
fun conversationSummaryToEntity() {
val summary = ConversationSummary(
id = "conv-1",
title = "Test Chat",
locked = false,
pinned = true,
updatedAt = "2026-06-21T12:00:00Z",
)
val entity = summary.toEntity()
assertEquals("conv-1", entity.id)
assertEquals("Test Chat", entity.title)
assertEquals(false, entity.locked)
assertEquals(true, entity.pinned)
assertEquals("2026-06-21T12:00:00Z", entity.updatedAt)
assertTrue(entity.cachedAt > 0)
}
@Test
fun conversationEntityToSummary() {
val entity = ConversationEntity(
id = "conv-2",
title = "Another Chat",
locked = true,
pinned = false,
updatedAt = null,
cachedAt = 1000L,
)
val summary = entity.toSummary()
assertEquals("conv-2", summary.id)
assertEquals("Another Chat", summary.title)
assertEquals(true, summary.locked)
assertEquals(false, summary.pinned)
assertEquals(null, summary.updatedAt)
}
@Test
fun conversationRoundTrip() {
val original = ConversationSummary(
id = "rt-1", title = "Round Trip", locked = false, pinned = true,
updatedAt = "2026-06-21T08:00:00Z",
)
val roundTripped = original.toEntity().toSummary()
assertEquals(original.id, roundTripped.id)
assertEquals(original.title, roundTripped.title)
assertEquals(original.locked, roundTripped.locked)
assertEquals(original.pinned, roundTripped.pinned)
assertEquals(original.updatedAt, roundTripped.updatedAt)
}
@Test
fun storedMessageToEntity() {
val attachment = Attachment(url = "https://example.com/img.png", kind = "image", name = "img.png")
val msg = StoredMessage(
id = "msg-1",
role = "assistant",
content = "Hello there",
attachments = listOf(attachment),
)
val entity = msg.toEntity(conversationId = "conv-1", sortOrder = 3)
assertEquals("msg-1", entity.id)
assertEquals("conv-1", entity.conversationId)
assertEquals("assistant", entity.role)
assertEquals("Hello there", entity.content)
assertEquals(3, entity.sortOrder)
assertEquals(1, entity.attachments.size)
assertEquals("img.png", entity.attachments[0].name)
}
@Test
fun messageEntityToStoredMessage() {
val entity = MessageEntity(
id = "msg-2",
conversationId = "conv-1",
role = "user",
content = "Hi",
attachments = emptyList(),
sortOrder = 0,
)
val stored = entity.toStoredMessage()
assertEquals("msg-2", stored.id)
assertEquals("user", stored.role)
assertEquals("Hi", stored.content)
assertTrue(stored.attachments.isEmpty())
}
@Test
fun messageRoundTrip() {
val attachment = Attachment(url = "https://f.co/a.pdf", kind = "document", name = "a.pdf", mimeType = "application/pdf", size = 1024)
val original = StoredMessage(id = "rt-msg", role = "user", content = "Check this", attachments = listOf(attachment))
val roundTripped = original.toEntity("c1", 5).toStoredMessage()
assertEquals(original.id, roundTripped.id)
assertEquals(original.role, roundTripped.role)
assertEquals(original.content, roundTripped.content)
assertEquals(original.attachments.size, roundTripped.attachments.size)
assertEquals(original.attachments[0].url, roundTripped.attachments[0].url)
assertEquals(original.attachments[0].mimeType, roundTripped.attachments[0].mimeType)
assertEquals(original.attachments[0].size, roundTripped.attachments[0].size)
}
}

View file

@ -1,67 +1,56 @@
package dev.kaizen.app package dev.kaizen.app
import dev.kaizen.app.settings.AppAppearance import dev.kaizen.app.settings.AppAppearance
import dev.kaizen.app.settings.KaizenThemeId
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test 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 { class SettingsViewModelTest {
@Test @Test
fun testInitialState() { fun testInitialState() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
assertEquals(KaizenThemeId.AMBER, viewModel.selectedTheme.value)
// Assert correct default states assertEquals(AppAppearance.System, viewModel.appearanceMode.value)
assertEquals(KaizenThemeId.Amber, viewModel.selectedTheme.value) assertEquals("", viewModel.userName.value)
assertEquals(AppAppearance.Dark, viewModel.appearanceMode.value)
assertEquals("Bruno", viewModel.userName.value)
assertEquals("gemini-2.5-flash", viewModel.defaultModel.value)
} }
@Test @Test
fun testSelectTheme() { fun testSelectTheme() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
viewModel.selectTheme(KaizenThemeId.AURORA)
// Transition theme to Aurora assertEquals(KaizenThemeId.AURORA, viewModel.selectedTheme.value)
viewModel.selectTheme(KaizenThemeId.Aurora) }
assertEquals(KaizenThemeId.Aurora, viewModel.selectedTheme.value) @Test
fun testSelectAllThemes() {
val viewModel = SettingsViewModel()
KaizenThemeId.entries.forEach { id ->
viewModel.selectTheme(id)
assertEquals(id, viewModel.selectedTheme.value)
}
} }
@Test @Test
fun testSelectAppearance() { fun testSelectAppearance() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
// Switch appearance to Light Mode
viewModel.selectAppearance(AppAppearance.Light) viewModel.selectAppearance(AppAppearance.Light)
assertEquals(AppAppearance.Light, viewModel.appearanceMode.value) assertEquals(AppAppearance.Light, viewModel.appearanceMode.value)
} }
@Test @Test
fun testUpdateUserName() { fun testUpdateUserName() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
viewModel.updateUserName(" Giuseppe ")
// Update user's name
viewModel.updateUserName(" Giuseppe ") // Should be trimmed
assertEquals("Giuseppe", viewModel.userName.value) assertEquals("Giuseppe", viewModel.userName.value)
} }
@Test @Test
fun testUpdateUserNameBlankIgnored() { fun testUpdateUserNameBlankIgnored() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
viewModel.updateUserName("Bruno")
// Attempting to set an empty/blank name should be ignored
viewModel.updateUserName(" ") viewModel.updateUserName(" ")
assertEquals("Bruno", viewModel.userName.value)
assertEquals("Bruno", viewModel.userName.value) // Remains unchanged
} }
} }

View 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],
)
}
}
}

View 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())
}
}

View file

@ -2,21 +2,16 @@ package dev.kaizen.app
import dev.kaizen.app.net.StreamConsumer import dev.kaizen.app.net.StreamConsumer
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test 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 { class StreamConsumerTest {
private val usage = 0.toChar() // private val usage = 0.toChar()
private val reasoning = 1.toChar() // private val reasoning = 1.toChar()
private val sources = 2.toChar() // private val sources = 2.toChar()
private val tool = 3.toChar() // private val tool = 3.toChar()
private val query = 4.toChar() // private val query = 4.toChar()
// ── Batch parser (visibleText) ────────────────────────────────────────── // ── Batch parser (visibleText) ──────────────────────────────────────────
@ -27,38 +22,27 @@ class StreamConsumerTest {
@Test @Test
fun usageSentinel_isClipped() { fun usageSentinel_isClipped() {
val raw = "Antwort.$usage{\"i\":12,\"o\":34}" assertEquals("Antwort.", StreamConsumer.visibleText("Antwort.$usage{\"i\":12,\"o\":34}"))
assertEquals("Antwort.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun reasoning_isStripped_contentKept() { fun reasoning_isStripped_contentKept() {
val raw = "${reasoning}denke nach${reasoning}Die Antwort ist 42." assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText("${reasoning}denke nach${reasoning}Die Antwort ist 42."))
assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun reasoning_thenContent_thenUsage() { fun reasoning_thenContent_thenUsage() {
val raw = "${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}" assertEquals("Fertig.", StreamConsumer.visibleText("${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}"))
assertEquals("Fertig.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun searchQueryAndSources_areClipped() { fun searchQueryAndSources_areClipped() {
val raw = "Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}" assertEquals("Hier das Wetter.", StreamConsumer.visibleText("Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}"))
assertEquals("Hier das Wetter.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun leadingToolEvents_areStripped() { fun leadingToolEvents_areStripped() {
val raw = "$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort." assertEquals("Die Antwort.", StreamConsumer.visibleText("$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))
} }
@Test @Test
@ -66,121 +50,100 @@ class StreamConsumerTest {
assertEquals("", StreamConsumer.visibleText("")) assertEquals("", StreamConsumer.visibleText(""))
} }
@Test // ── Incremental parser — content ───────────────────────────────────────
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.
@Test @Test
fun incremental_plainText_singleChunk() { fun incremental_plainText() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("Hallo Welt", inc.append("Hallo Welt")) assertEquals("Hallo Welt", inc.append("Hallo Welt").content)
} }
@Test @Test
fun incremental_plainText_charByChar() { fun incremental_plainText_charByChar() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
val input = "Hallo Welt" var state = inc.append("")
var result = "" for (c in "Hallo Welt") state = inc.append(c.toString())
for (c in input) result = inc.append(c.toString()) assertEquals("Hallo Welt", state.content)
assertEquals("Hallo Welt", result)
} }
@Test @Test
fun incremental_usageSentinel_isClipped() { fun incremental_usageSentinel_isClipped() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("Antwort.", inc.append("Antwort.")) inc.append("Antwort.")
assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}")) assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}").content)
} }
@Test @Test
fun incremental_reasoning_isStripped() { fun incremental_reasoning_captured() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("${reasoning}denke")) inc.append("${reasoning}denke nach")
assertEquals("", inc.append(" nach")) val state = inc.append("${reasoning}Die Antwort.")
assertEquals("Die ", inc.append("${reasoning}Die ")) assertEquals("Die Antwort.", state.content)
assertEquals("Die Antwort ist 42.", inc.append("Antwort ist 42.")) assertEquals("denke nach", state.reasoning)
} }
@Test @Test
fun incremental_reasoning_thenContent_thenUsage() { fun incremental_reasoning_thenContent_thenUsage() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
inc.append("${reasoning}grübel${reasoning}") inc.append("${reasoning}grübel${reasoning}")
val result = inc.append("Fertig.$usage{\"i\":1,\"o\":2}") val state = inc.append("Fertig.$usage{\"i\":1,\"o\":2}")
assertEquals("Fertig.", result) assertEquals("Fertig.", state.content)
assertEquals("grübel", state.reasoning)
} }
@Test @Test
fun incremental_searchQueryAndSources_areClipped() { fun incremental_sources_parsed() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
inc.append("Hier das Wetter.") val state = inc.append("Wetter.${query}\"berlin wetter\"${sources}[{\"title\":\"t\",\"url\":\"u\",\"snippet\":\"s\"}]$usage{}")
val result = inc.append("${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}") assertEquals("Wetter.", state.content)
assertEquals("Hier das Wetter.", result) assertEquals("\"berlin wetter\"", state.query)
assertEquals(1, state.sources.size)
assertEquals("t", state.sources[0].title)
assertEquals("u", state.sources[0].url)
} }
@Test @Test
fun incremental_toolEvents_singleChunk() { fun incremental_toolEvents_parsed() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
val raw = "$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort." val state = inc.append("$tool{\"type\":\"start\",\"name\":\"web_search\"}\n${tool}{\"type\":\"end\",\"name\":\"web_search\",\"elapsed\":120}\nAntwort.")
assertEquals("Die Antwort.", inc.append(raw)) 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 @Test
fun incremental_toolEvents_splitAcrossChunks() { fun incremental_toolEvents_splitAcrossChunks() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("$tool{\"type\":\"thin")) inc.append("$tool{\"type\":\"sta")
assertEquals("", inc.append("king\"}\n$tool{\"type\":\"sta")) inc.append("rt\",\"name\":\"x\"}\n")
assertEquals("Die Antwort.", inc.append("rt\"}\nDie Antwort.")) val state = inc.append("Content.")
} assertEquals("Content.", state.content)
assertEquals(1, state.tools.size)
@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"))
} }
@Test @Test
fun incremental_afterDone_ignoresMore() { fun incremental_afterDone_ignoresMore() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
inc.append("Fertig.") inc.append("Fertig.")
val afterUsage = inc.append("${usage}{\"i\":1}") inc.append("${usage}{\"i\":1}")
assertEquals("Fertig.", afterUsage) assertEquals("Fertig.", inc.append("ignored").content)
assertEquals("Fertig.", inc.append("ignored trailing garbage"))
} }
@Test @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() val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("$tool{\"type\":\"thinking\"}\n")) var state = inc.append("")
assertEquals("", inc.append("${reasoning}deep thought")) for (c in full) state = inc.append(c.toString())
assertEquals("42", inc.append("${reasoning}42")) assertEquals("Die Antwort.", state.content)
} assertEquals("grübel", state.reasoning)
assertEquals(1, state.tools.size)
@Test assertEquals("t", state.tools[0].name)
fun incremental_matchesBatch_fullStream() { assertEquals("\"q\"", state.query)
val full = "$tool{\"type\":\"thinking\"}\n${reasoning}grübel${reasoning}Die Antwort.${query}q${sources}s$usage{}" assertEquals(1, state.sources.size)
val expected = StreamConsumer.visibleText(full) assertEquals("x", state.sources[0].title)
val inc = StreamConsumer.Incremental()
var result = ""
for (c in full) result = inc.append(c.toString())
assertEquals(expected, result)
} }
} }

View 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(""))
}
}

View file

@ -2,4 +2,5 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
} }

View file

@ -12,4 +12,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true # org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
# KSP + AGP 9.x built-in Kotlin compatibility (github.com/google/ksp/issues/2729)
android.disallowKotlinSourceSets=false

View file

@ -7,16 +7,20 @@ espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
kotlin = "2.2.10" kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
composeBom = "2026.02.01" composeBom = "2026.02.01"
okhttp = "4.12.0" okhttp = "4.12.0"
kotlinxSerialization = "1.7.3" kotlinxSerialization = "1.7.3"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
room = "2.8.4"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@ -37,4 +41,5 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material"
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }