Compare commits

...

3 commits

Author SHA1 Message Date
Bruno Deanoz
7aefd7ba70 fix: migrate ClickableText to LinkAnnotation, add server version check
Replace deprecated ClickableText with LinkAnnotation.Url + Text (Compose
native URI handling). Add GET /api/v1/meta call after login and on each
app resume to detect API version skew — shows dismissible amber banner.
2026-06-23 14:51:20 +02:00
Bruno Deanoz
e6fa8ef8a6 docs: restore completed sections in CLAUDE.md, fix numbering
Keep [x] items as context for future sessions. Added new done items
(text selection, ChatInput capsule, visual refresh). Fixed section
numbering 1-8. Updated media cache to reflect custom disk cache.
2026-06-23 14:40:47 +02:00
Bruno Deanoz
dca9cb7593 docs: update CLAUDE.md with visual refresh + UX overhaul changes
New sections: ChatInput capsule, semantic colors, custom icons,
glass-lens app icon, text selection, flat sidebar profile.
Updated: typography (W300), blob sizes, top bar layout, icon system.
Added full bugs-fixed section for 2026-06-23 design session.
2026-06-23 14:37:53 +02:00
8 changed files with 131 additions and 60 deletions

View file

@ -48,7 +48,7 @@ settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecurityS
ui/ ui/
theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither
theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry
icon/ KaizenIcons (central registry, Lucide base, custom override layer) icon/ KaizenIcons (central registry, Lucide base, custom override layer), KaizenCustomIcons (hand-drawn 1.0px stroke)
shape/ SquircleShape (superellipse), KaizenShapes (token system) shape/ SquircleShape (superellipse), KaizenShapes (token system)
effect/ GlassSurface (frosted glass), Shadow (multi-layer stack) effect/ GlassSurface (frosted glass), Shadow (multi-layer stack)
motion/ Motion (easing curves, spring specs, duration tokens) motion/ Motion (easing curves, spring specs, duration tokens)
@ -75,7 +75,7 @@ Full spec: `DESIGN.md`.
4. **Multi-layer shadows**`ShadowStack` with 24 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`. 4. **Multi-layer shadows**`ShadowStack` with 24 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`.
5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. 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. 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), `SENSOR_DELAY_UI` (~15 Hz, sufficient for parallax). Auto-disables on battery saver, reduced motion, background, extreme tilt. 6. **Sensor-reactive motion**`rememberTiltState()` provides smoothed tilt `Offset(-1..1)`. Uses `TYPE_GAME_ROTATION_VECTOR` (fused, no drift), `SENSOR_DELAY_UI` (~15 Hz, sufficient for parallax). Auto-disables on battery saver, reduced motion, background, extreme tilt.
7. **KaizenIcons** (`ui/icon/KaizenIcons.kt`) — centralized icon registry, same architecture as Google's Luminous Symbols. Lucide (1.5px stroke, same library as web frontend) as base, with a custom override layer for brand-critical icons. Every UI component references `KaizenIcons.Send`, `KaizenIcons.Lock`, etc. — never a library directly. Zero Material Icon imports remain. To add a custom icon: create an `ImageVector` literal (hand-drawn SVG converted to Compose path data) and swap the getter in `KaizenIcons.kt` — all usages update instantly, no call-site changes. Icons grouped semantically: Navigation, Chat (incl. `AudioLines` for call feature), Modes, Conversation, Files, Search, Settings, Scroll. `com.composables:icons-lucide-android:1.0.0`. 7. **KaizenIcons** (`ui/icon/KaizenIcons.kt`) — centralized icon registry, same architecture as Google's Luminous Symbols. Lucide (1.5px stroke, same library as web frontend) as base, with a custom override layer (`KaizenCustomIcons.kt`) for the highest-visibility touchpoints. Custom icons use 1.0px stroke to match the lighter W300 typography. Currently custom: Menu, Plus, Mic, AudioLines, SlidersHorizontal, Globe, Image. Every UI component references `KaizenIcons.Send`, `KaizenIcons.Lock`, etc. — never a library directly. Zero Material Icon imports remain. `com.composables:icons-lucide-android:1.0.0`.
**Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object. **Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object.
@ -83,15 +83,19 @@ Full spec: `DESIGN.md`.
**Theme switching fully wired:** All 7 files that previously imported hardcoded `Amber`/`AmberDeep`/`OnAmber`/`BlobX` now read from `LocalKaizenAccent.current`. Changing theme in Settings immediately updates: message bubbles, send button, orb, mesh background blobs, sidebar highlights, model sheet, stream blocks, login screen, settings toggles. No hardcoded amber imports remain outside `Color.kt` and theme definitions. **Theme switching fully wired:** All 7 files that previously imported hardcoded `Amber`/`AmberDeep`/`OnAmber`/`BlobX` now read from `LocalKaizenAccent.current`. Changing theme in Settings immediately updates: message bubbles, send button, orb, mesh background blobs, sidebar highlights, model sheet, stream blocks, login screen, settings toggles. No hardcoded amber imports remain outside `Color.kt` and theme definitions.
**Floating bar scrims:** Top bar and bottom dock have soft transparent gradient scrims. Bottom dock: 0% → 25% → 55% alpha when keyboard closed, 50% → 85% when keyboard open. Mesh background bleeds through — no opaque block. **Floating bar scrims:** Top bar has soft transparent gradient scrim. Bottom dock is the ChatInput capsule itself (no separate scrim needed).
**Unified top bar:** Sidebar trigger (menu icon) and model name merged into a single `GlassSurface` pill (38dp, `KaizenShapes.pill`). Menu icon (17dp, muted) → 3dp dot divider → model name (13sp Medium, 180dp max with ellipsis). No chevron — pill is self-evidently tappable. Matches the TokenBadge glass language on the right. **Unified top bar:** Sidebar trigger (menu icon) and model name merged into a single `GlassSurface` pill (38dp, `KaizenShapes.pill`). Menu icon (17dp, muted) → 3dp dot divider → model name (13sp W300, 180dp max with ellipsis). No chevron — pill is self-evidently tappable. Right side: PenLine "New Chat" button → TokenBadge.
**MeshBackground** (`chat/Background.kt`) — `animateBlobs` parameter: blob drift animation (22s cycle) only runs on empty state (`messages.isEmpty()`), frozen at midpoint during chat to save GPU. Blobs remain visible as static background. **ChatInput capsule:** Single-row Gemini-style input — Plus | TextField | Tune | Mic | Send/Call. 64dp min height, pill shape (34dp radius), level4 shadow, high opacity (0.92/0.97). Mode pills hidden by default, expandable via tune icon (SlidersHorizontal) with slide animation inside the same GlassSurface card. Replaces the old two-row layout + separate floating pills row.
**MeshBackground** (`chat/Background.kt`) — `animateBlobs` parameter: blob drift animation (22s cycle) only runs on empty state (`messages.isEmpty()`), frozen at midpoint during chat to save GPU. Blobs remain visible as static background. Blobs are 400-460dp (large, fill entire screen on S25 Ultra), alpha 0.36-0.48 (dark) with light-mode factor 0.78 (was 0.65). Vignette 0.35 alpha.
**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/`. **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`. **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`. **Visual refresh (2026-06-23):** Body text W400→W300 for airier feel (inspired by Gemini's Neural Expressive). Headings scaled up: H1 26sp/W600, H2 22sp/W600, H3 18sp/W500. User bubble text, input field, hero all W300. Labels/pills stay W400-W500 for legibility.
**Semantic Colors** — `KaizenSemanticColors` object in `Color.kt`: `error`, `errorDeep`, `success`, `successBright`, `indigo`, `linkLight/Dark`, `dropdownDark/Light`. Single source of truth for status/action colors — no `Color(0xFF...)` for these outside `Color.kt`. Syntax highlighting colors (Dracula theme) and glass-surface tints (Slate) intentionally stay local.
**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. **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.
@ -148,7 +152,7 @@ The `/api/v1/chat` stream embeds control sections via sentinel code points (from
### App icon ### 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. Adaptive icon: translucent glass-lens orb (8 layers — subtle amber tint at 50-60% alpha, light speculars, thin rims). Matches the in-app KaizenOrb aesthetic (glass, not opaque paint). Light mode: neutral off-white background (#F6F7F9). Dark mode: Obsidian gradient (#0F1520). Monochrome layer for Android 13+ themed icons. Splash screen uses orb foreground via `windowSplashScreenAnimatedIcon` (API 31+). Vector-only — no raster WebPs.
### Offline-first cache layer (Room) — added 2026-06-21 ### Offline-first cache layer (Room) — added 2026-06-21
@ -279,6 +283,15 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
| `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, removes message + all after it, puts text back in input field | | `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, removes message + all after it, puts text back in input field |
| `b2df7e9` | **Modern message action icons** — ClipboardCopy (copy), SquarePen (edit), RotateCcw (regenerate), 36dp touch target, 20dp icons | | `b2df7e9` | **Modern message action icons** — ClipboardCopy (copy), SquarePen (edit), RotateCcw (regenerate), 36dp touch target, 20dp icons |
| `421b29e` | **ReasoningBlock + SourceCards redesign** — accent-tinted background + gradient borders, KaizenShapes.md (16dp radius), domain instead of full URL in source cards | | `421b29e` | **ReasoningBlock + SourceCards redesign** — accent-tinted background + gradient borders, KaizenShapes.md (16dp radius), domain instead of full URL in source cards |
| `75ca8c0` | **Visual refresh** — body text W400→W300 (airier), headings scaled up (H1 26sp, H2 22sp, H3 18sp), `animateItem()` on messages (350ms fade-in, spring placement), blobs 20% larger + better distribution, higher alpha on OLED, light-mode factor 0.65→0.78, distinct `primaryDeep` per theme |
| `38678a2` | **KaizenSemanticColors** — extracted ~25 hardcoded `Color(0xFF...)` into semantic tokens (error, success, indigo, link, dropdown). Single source of truth in `Color.kt` |
| `0f6e55d` | **NetworkImageCache upgrade** — disk cache (`cacheDir/img`), shared `KaizenApi.imageClient` (reuses connection pool), 32MB size-based memory limit, Crossfade animation on load |
| `3c6a891` | **Custom 1.0px icons**`KaizenCustomIcons.kt` with hand-drawn Menu, Plus, Mic, AudioLines, SlidersHorizontal, Globe, Image. Thinner stroke matches W300 typography |
| `ad98b9f` | **App icon + splash redesign** — translucent glass-lens orb (was opaque plastic "Spiegelei"). Neutral backgrounds matching app colors |
| `4958ca8` | **Single-row ChatInput** — Gemini-style capsule: Plus \| TextField \| Tune \| Mic \| Send/Call. Mode pills hidden by default, expandable via tune icon inside the same card. New Chat button (PenLine) in top bar |
| `1c9491e` | **Text selection**`SelectionContainer` on user + assistant messages. Long-press to select, copy, share |
| `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset |
| `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator |
**Earlier UI/feel work (Phase 0 prototype → feel-first):** **Earlier UI/feel work (Phase 0 prototype → feel-first):**
- Liquid glass styling, floating panels, glassmorphic sidebar - Liquid glass styling, floating panels, glassmorphic sidebar
@ -286,7 +299,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
- Mesh gradient background with dithered blobs - Mesh gradient background with dithered blobs
- Phase-aware haptics (click → thinkingStart → responseStart → responseEnd) - Phase-aware haptics (click → thinkingStart → responseStart → responseEnd)
- Tablet/landscape optimization (width capping) - Tablet/landscape optimization (width capping)
- Dynamic keyboard handling: `WindowInsets.ime` offset (NOT `imePadding()` — that inflates the dock; NOT `adjustResize` — hides the dock with edge-to-edge). Mode pills hide when keyboard open. `windowSoftInputMode=adjustNothing` in manifest. - Dynamic keyboard handling: `WindowInsets.ime` offset (NOT `imePadding()` — that inflates the dock; NOT `adjustResize` — hides the dock with edge-to-edge). `windowSoftInputMode=adjustNothing` in manifest.
- Settings screen with SettingsViewModel - Settings screen with SettingsViewModel
### Bugs fixed (2026-06-19/20/21) ### Bugs fixed (2026-06-19/20/21)
@ -337,22 +350,44 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
- **No edit/resend for user messages** — only copy + delete were available. Fix: SquarePen button removes message + successors, puts text back in input. - **No edit/resend for user messages** — only copy + delete were available. Fix: SquarePen button removes message + successors, puts text back in input.
- **`webSearchProvider` not sent by app** — Vertex's Google vs Enterprise search toggle was missing. Fix: added `webSearchProvider` to `ChatRequest`, dropdown pill in mode pills row (only visible for `vertex:` models with search active). - **`webSearchProvider` not sent by app** — Vertex's Google vs Enterprise search toggle was missing. Fix: added `webSearchProvider` to `ChatRequest`, dropdown pill in mode pills row (only visible for `vertex:` models with search active).
### Visual refresh + UX overhaul (2026-06-23 — design session)
- **Typography too heavy** — W400 body everywhere looked dense. Fix: W300 for body/input/hero, W600/W500 for headings (inspired by Gemini's Neural Expressive thinner Roboto Flex).
- **Headings too small** — H1 22sp, H2 19sp, H3 17sp. Fix: H1 26sp, H2 22sp, H3 18sp with proper line-height.
- **Messages pop in without animation** — no entry/exit transition. Fix: `animateItem()` on LazyColumn (350ms fade-in, spring placement, 200ms fade-out).
- **Themes washed out / blobs too small** — bottom half of screen had no color, light mode barely visible. Fix: blobs 20% larger (400-460dp), better full-screen distribution, higher alpha (0.48 peak), light factor 0.65→0.78, `primaryDeep` distinct per theme.
- **Hardcoded colors scattered** — ~25 `Color(0xFF...)` across 10 files. Fix: `KaizenSemanticColors` object in `Color.kt`.
- **Image cache had no disk persistence** — images re-downloaded every app restart. Fix: file-based disk cache in `cacheDir/img`, shared `KaizenApi.imageClient`, 32MB size-based memory limit, Crossfade on load.
- **App icon still old "Spiegelei"** — opaque plastic orb didn't match glass-lens in-app orb. Fix: redesigned to translucent glass-lens with neutral backgrounds.
- **Mode pills floating separately above input** — two disconnected UI elements, ugly scrim. Fix: pills moved inside ChatInput card, hidden by default, expandable via tune icon.
- **ChatInput was two-row layout** — wasted vertical space. Fix: single-row Gemini-style capsule (64dp, 34dp radius, level4 shadow).
- **No "New Chat" button** — only accessible via sidebar. Fix: PenLine icon in top bar.
- **Text not selectable** — couldn't select/copy text from messages. Fix: `SelectionContainer` on user + assistant messages.
- **Sidebar profile was heavy card** — background, border, shadow. Fix: flat row with thin separator.
- **Custom icons too thick** — Lucide 1.5px at 16-19dp looked chunky next to W300 text. Fix: 7 custom `ImageVector` icons at 1.0px stroke in `KaizenCustomIcons.kt`.
## What's NOT done yet (app, priority order) ## What's NOT done yet (app, priority order)
### 1. Remaining visual/UX gaps (daily-drive blockers) ### 1. Remaining visual/UX gaps (daily-drive blockers)
These are what separate "prototype" from "daily-driver": All done:
- [x] **Markdown rendering during streaming** — all regex patterns pre-compiled as top-level constants, `remember(text)` memoization. Verified performant - [x] **Markdown rendering during streaming** — all regex patterns pre-compiled as top-level constants, `remember(text)` memoization. Verified performant
- [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback) - [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback)
- [x] **Horizontal rule / blockquote rendering**`---` and `> quote` parsed and rendered - [x] **Horizontal rule / blockquote rendering**`---` and `> quote` parsed and rendered
- [x] **Table rendering**`| header | header |` with separator detection, styled borders, header background, horizontal scroll for wide tables, inline markdown in cells - [x] **Table rendering**`| header | header |` with separator detection, styled borders, header background, horizontal scroll for wide tables, inline markdown in cells
- [x] **Link rendering** — clickable via `ClickableText` + `ACTION_VIEW`, opens browser on tap - [x] **Link rendering** — clickable via `LinkAnnotation.Url` + `LocalUriHandler`, opens browser on tap
- [x] **Strikethrough**`~~text~~` rendered with line-through + dimmed alpha - [x] **Strikethrough**`~~text~~` rendered with line-through + dimmed alpha
- [x] **Task lists**`- [ ]` / `- [x]` with Check/Square icons in accent color - [x] **Task lists**`- [ ]` / `- [x]` with Check/Square icons in accent color
- [x] **Text selection**`SelectionContainer` on user + assistant messages, long-press to select/copy/share
- [x] **Single-row ChatInput** — Gemini-style capsule (64dp, pill shape), mode pills inside card via tune icon
- [x] **New Chat button** — PenLine icon in top bar
- [x] **Visual refresh** — W300 typography, larger headings, message animations, richer themes/blobs, semantic colors, custom 1.0px icons, glass-lens app icon
### 2. Conversation management from the app ### 2. Conversation management from the app
All done:
- [x] **Conversation rename/delete** from the sidebar — 3-dot menu on each chat, `PATCH`/`DELETE /api/v1/conversations/[id]` - [x] **Conversation rename/delete** from the sidebar — 3-dot menu on each chat, `PATCH`/`DELETE /api/v1/conversations/[id]`
- [x] **Conversation pin/unpin** from the sidebar — `PATCH { pinned }`, star icon for pinned items - [x] **Conversation pin/unpin** from the sidebar — `PATCH { pinned }`, star icon for pinned items
- [x] **In-app unlock for locked conversations** — BiometricPrompt with CryptoObject (AES key in TEE/StrongBox), hardware-enforced biometric auth → server unlock token via `POST /api/v1/auth/unlock` → fetch messages with `X-Unlock-Token` header. Auto-lock on background. Fallback to direct unlock if biometrics unavailable - [x] **In-app unlock for locked conversations** — BiometricPrompt with CryptoObject (AES key in TEE/StrongBox), hardware-enforced biometric auth → server unlock token via `POST /api/v1/auth/unlock` → fetch messages with `X-Unlock-Token` header. Auto-lock on background. Fallback to direct unlock if biometrics unavailable
@ -360,7 +395,7 @@ These are what separate "prototype" from "daily-driver":
### 3. Security hardening ### 3. Security hardening
- [x] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner - [x] **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 - [x] **Server version check** — calls `GET /api/v1/meta` after login + on each foreground, compares `apiVersion` against `EXPECTED_API_VERSION`, shows dismissible amber warning banner on skew
- [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — hardware-enforced, password fallback dialog when unavailable, biometric toggle in Settings, all unlock paths gated - [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — hardware-enforced, password fallback dialog when unavailable, biometric toggle in Settings, all unlock paths gated
- [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change - [x] **Security settings** — biometric toggle, signed-in devices with remote revoke, password change
- [x] **Password change**`POST /api/v1/auth/change-password` (Argon2id, rate-limited) - [x] **Password change**`POST /api/v1/auth/change-password` (Argon2id, rate-limited)
@ -414,7 +449,7 @@ The web's call mode (`use-call-mode.ts`, `call-view.tsx`) is the reference imple
- [ ] **Stale-check enforcement** — use `cachedAt` to trigger background refresh when older than N minutes - [ ] **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) - [ ] **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) - [ ] **Incremental sync** — only fetch changed conversations (needs server-side `since` parameter)
- [ ] **Media cache** — cache images/files locally (Coil or custom disk cache) - [x] **Media cache**`NetworkImageCache` with file-based disk cache (`cacheDir/img`), 32MB size-based memory LRU, shared `KaizenApi.imageClient`
- [ ] **Full-text search** — FTS4/FTS5 over cached messages (Room supports this natively) - [ ] **Full-text search** — FTS4/FTS5 over cached messages (Room supports this natively)
- [ ] **Attachment queries** — "show me all chats with images/PDFs" via Room DB - [ ] **Attachment queries** — "show me all chats with images/PDFs" via Room DB

View file

@ -409,6 +409,7 @@ fun ChatScreen(
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ") loadError = errors.joinToString(" · ")
} }
scope.launch { session.checkServerVersion(cfg.baseUrl) }
scope.launch { scope.launch {
val ids = chat.conversationRepo.allUnlockedIds() val ids = chat.conversationRepo.allUnlockedIds()
chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids) chat.messageRepo.prefetchMissing(cfg.baseUrl, cfg.token, ids)
@ -1071,6 +1072,30 @@ fun ChatScreen(
} }
} }
// Version skew warning banner
val skew = session.versionSkew
if (skew != null) {
val warningColor = Color(0xFFD97706)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = if (loadError != null) 100.dp else 62.dp, start = 24.dp, end = 24.dp)
.clip(KaizenShapes.sm)
.background(warningColor.copy(alpha = 0.15f))
.border(1.dp, warningColor.copy(alpha = 0.3f), KaizenShapes.sm)
.clickable { session.dismissVersionWarning() }
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
stringResource(R.string.error_version_skew, skew.serverVersion, skew.expectedVersion),
color = warningColor,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
// 3. Floating Bottom Control Dock // 3. Floating Bottom Control Dock
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
val imeVisible = WindowInsets.isImeVisible val imeVisible = WindowInsets.isImeVisible

View file

@ -1,7 +1,5 @@
package dev.kaizen.app.chat package dev.kaizen.app.chat
import android.content.Intent
import android.net.Uri
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
@ -21,7 +19,6 @@ 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 androidx.compose.foundation.text.ClickableText
import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -38,8 +35,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.kaizen.app.ui.theme.KaizenSemanticColors import dev.kaizen.app.ui.theme.KaizenSemanticColors
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@ -204,8 +202,6 @@ private fun parseBlocks(src: String): List<MdBlock> {
// ── Inline markdown ──────────────────────────────────────────────────────────── // ── Inline markdown ────────────────────────────────────────────────────────────
private const val LINK_TAG = "URL"
private fun inlineMarkdown( private fun inlineMarkdown(
text: String, text: String,
baseColor: Color, baseColor: Color,
@ -225,10 +221,8 @@ private fun inlineMarkdown(
if (closeParen > closeBracket) { if (closeParen > closeBracket) {
val linkText = src.substring(pos + 1, closeBracket) val linkText = src.substring(pos + 1, closeBracket)
val url = src.substring(closeBracket + 2, closeParen) val url = src.substring(closeBracket + 2, closeParen)
pushStringAnnotation(tag = LINK_TAG, annotation = url) pushLink(LinkAnnotation.Url(url, TextLinkStyles(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline))))
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
append(linkText) append(linkText)
}
pop() pop()
pos = closeParen + 1 pos = closeParen + 1
continue continue
@ -440,7 +434,6 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) { fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val blocks = remember(text) { parseBlocks(text) } val blocks = remember(text) { parseBlocks(text) }
val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6) val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6)
@ -461,7 +454,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
is MdBlock.Heading -> { is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(if (block.level == 1) 24.dp else 20.dp)) if (idx > 0) Spacer(Modifier.height(if (block.level == 1) 24.dp else 20.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onBackground, ClickableMarkdownText(rendered, cs.onBackground,
fontSize = when (block.level) { 1 -> 26.sp; 2 -> 22.sp; else -> 18.sp }, fontSize = when (block.level) { 1 -> 26.sp; 2 -> 22.sp; else -> 18.sp },
fontWeight = when (block.level) { 1 -> FontWeight.W600; 2 -> FontWeight.W600; else -> FontWeight.W500 }, fontWeight = when (block.level) { 1 -> FontWeight.W600; 2 -> FontWeight.W600; else -> FontWeight.W500 },
lineHeight = when (block.level) { 1 -> 34.sp; 2 -> 30.sp; else -> 26.sp }, lineHeight = when (block.level) { 1 -> 34.sp; 2 -> 30.sp; else -> 26.sp },
@ -593,7 +586,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
val appendCursor = isLast && itemIdx == block.items.lastIndex val appendCursor = isLast && itemIdx == block.items.lastIndex
val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onBackground, ClickableMarkdownText(rendered, cs.onBackground,
fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300, fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@ -630,7 +623,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onSurfaceVariant, ClickableMarkdownText(rendered, cs.onSurfaceVariant,
fontSize = 15.sp, lineHeight = 24.sp, fontSize = 15.sp, lineHeight = 24.sp,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -642,7 +635,7 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(12.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onBackground, ClickableMarkdownText(rendered, cs.onBackground,
fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300, fontSize = 16.sp, lineHeight = 26.sp, fontWeight = FontWeight.W300,
) )
} }
@ -653,12 +646,11 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
} }
// ── Clickable text (handles link taps) ───────────────────────────────────────── // ── Clickable text (handles link taps via LinkAnnotation + LocalUriHandler) ────
@Composable @Composable
private fun ClickableMarkdownText( private fun ClickableMarkdownText(
text: AnnotatedString, text: AnnotatedString,
context: android.content.Context,
color: Color, color: Color,
fontSize: androidx.compose.ui.unit.TextUnit = 16.sp, fontSize: androidx.compose.ui.unit.TextUnit = 16.sp,
lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp, lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp,
@ -666,27 +658,6 @@ private fun ClickableMarkdownText(
fontStyle: FontStyle? = null, fontStyle: FontStyle? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hasLinks = text.getStringAnnotations(LINK_TAG, 0, text.length).isNotEmpty()
if (hasLinks) {
ClickableText(
text = text,
style = androidx.compose.ui.text.TextStyle(
color = color,
fontSize = fontSize,
lineHeight = lineHeight,
fontWeight = fontWeight,
fontStyle = fontStyle,
),
modifier = modifier,
onClick = { offset ->
text.getStringAnnotations(LINK_TAG, offset, offset).firstOrNull()?.let { ann ->
try {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ann.item)))
} catch (_: Exception) {}
}
},
)
} else {
Text( Text(
text = text, text = text,
color = color, color = color,
@ -697,7 +668,6 @@ private fun ClickableMarkdownText(
modifier = modifier, modifier = modifier,
) )
} }
}
// ── Copy button ──────────────────────────────────────────────────────────────── // ── Copy button ────────────────────────────────────────────────────────────────

View file

@ -611,6 +611,19 @@ object KaizenApi {
} }
} }
suspend fun fetchMeta(baseUrl: String): MetaResponse? = withContext(Dispatchers.IO) {
val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use null
json.decodeFromString<MetaResponse>(resp.body!!.string())
}
} catch (e: Exception) {
Log.w(TAG, "fetchMeta: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
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()
@ -678,4 +691,10 @@ object KaizenApi {
} }
} }
@Serializable data class MetaResponse(
val app: String = "",
val apiVersion: Int = 0,
val features: List<String> = emptyList(),
)
@Serializable private data class ErrorBody(val error: String = "unknown") @Serializable private data class ErrorBody(val error: String = "unknown")

View file

@ -37,5 +37,8 @@ const val DEFAULT_BASE_URL = "https://ask.kryptomrx.de"
/** Fallback model when the server reports no user default (GET /api/v1/me → defaultModel null). */ /** Fallback model when the server reports no user default (GET /api/v1/me → defaultModel null). */
const val DEFAULT_MODEL = "vertex:gemini-2.5-flash" const val DEFAULT_MODEL = "vertex:gemini-2.5-flash"
/** The v1 API version this app build expects from the server. */
const val EXPECTED_API_VERSION = 1
/** Strips a trailing slash so "$baseUrl/api/v1/..." never double-slashes. */ /** Strips a trailing slash so "$baseUrl/api/v1/..." never double-slashes. */
fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/') fun normalizeBaseUrl(input: String): String = input.trim().trimEnd('/')

View file

@ -12,6 +12,8 @@ import androidx.compose.runtime.setValue
* *
* Held at Activity scope (instantiated directly like SettingsViewModel) no DI yet. * Held at Activity scope (instantiated directly like SettingsViewModel) no DI yet.
*/ */
data class VersionSkew(val serverVersion: Int, val expectedVersion: Int)
class SessionViewModel(val store: SecureStore) { class SessionViewModel(val store: SecureStore) {
var config by mutableStateOf(store.load()) var config by mutableStateOf(store.load())
@ -21,8 +23,13 @@ class SessionViewModel(val store: SecureStore) {
var favorites by mutableStateOf(store.loadFavorites()) var favorites by mutableStateOf(store.loadFavorites())
private set private set
var versionSkew by mutableStateOf<VersionSkew?>(null)
private set
val isLoggedIn: Boolean get() = config != null val isLoggedIn: Boolean get() = config != null
fun dismissVersionWarning() { versionSkew = null }
/** /**
* In-app login: mint a token, then resolve the user's default model from the * In-app login: mint a token, then resolve the user's default model from the
* server (falling back to [DEFAULT_MODEL]). On success the config is persisted * server (falling back to [DEFAULT_MODEL]). On success the config is persisted
@ -36,12 +43,22 @@ class SessionViewModel(val store: SecureStore) {
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim()) val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg) store.save(cfg)
config = cfg config = cfg
checkServerVersion(baseUrl)
result result
} }
else -> result else -> result
} }
} }
suspend fun checkServerVersion(baseUrl: String) {
val meta = KaizenApi.fetchMeta(baseUrl) ?: return
if (meta.apiVersion != EXPECTED_API_VERSION) {
versionSkew = VersionSkew(meta.apiVersion, EXPECTED_API_VERSION)
} else {
versionSkew = null
}
}
/** Switch the chat model (persisted). No-op if not logged in. */ /** Switch the chat model (persisted). No-op if not logged in. */
fun setModel(modelId: String) { fun setModel(modelId: String) {
val cfg = config ?: return val cfg = config ?: return

View file

@ -41,6 +41,7 @@
<string name="error_rate_limited">Too many requests. Please wait.</string> <string name="error_rate_limited">Too many requests. Please wait.</string>
<string name="error_server">Server error (%1$d).</string> <string name="error_server">Server error (%1$d).</string>
<string name="error_no_connection">No connection to server.</string> <string name="error_no_connection">No connection to server.</string>
<string name="error_version_skew">Server API v%1$d — app expects v%2$d. Please update server or app.</string>
<!-- Mode pills --> <!-- Mode pills -->
<string name="mode_standard">Standard</string> <string name="mode_standard">Standard</string>

View file

@ -43,6 +43,7 @@
<string name="error_rate_limited">Zu viele Anfragen. Kurz warten.</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_server">Fehler vom Server (%1$d).</string>
<string name="error_no_connection">Keine Verbindung zum Server.</string> <string name="error_no_connection">Keine Verbindung zum Server.</string>
<string name="error_version_skew">Server API v%1$d — App erwartet v%2$d. Bitte Server oder App aktualisieren.</string>
<!-- Mode pills --> <!-- Mode pills -->
<string name="mode_standard">Standard</string> <string name="mode_standard">Standard</string>