Compare commits
No commits in common. "e62c5253868b028964194f3f0b66628c96286c16" and "f8e1747df4db2596095b8d8e339fc5ca253b179b" have entirely different histories.
e62c525386
...
f8e1747df4
14 changed files with 105 additions and 1038 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -35,7 +35,7 @@ Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks t
|
||||||
### Package structure (`app/src/main/java/dev/kaizen/app/`)
|
### Package structure (`app/src/main/java/dev/kaizen/app/`)
|
||||||
|
|
||||||
```
|
```
|
||||||
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject), PasswordUnlockDialog
|
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject)
|
||||||
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
|
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
|
||||||
ModelSheet, Markdown, Background
|
ModelSheet, Markdown, Background
|
||||||
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
|
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
|
||||||
|
|
@ -43,7 +43,7 @@ db/ Room offline cache: KaizenDatabase, Entities, DAOs,
|
||||||
haptics/ Phase-aware haptics
|
haptics/ Phase-aware haptics
|
||||||
net/ KaizenApi (HTTP), SecureStore (encrypted prefs),
|
net/ KaizenApi (HTTP), SecureStore (encrypted prefs),
|
||||||
SessionViewModel, ServerConfig, StreamConsumer
|
SessionViewModel, ServerConfig, StreamConsumer
|
||||||
settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecuritySection
|
settings/ SettingsScreen, SettingsViewModel, SettingsComponents
|
||||||
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
|
||||||
|
|
@ -100,13 +100,12 @@ Full spec: `DESIGN.md`.
|
||||||
|
|
||||||
### Settings persistence + server sync
|
### Settings persistence + server sync
|
||||||
|
|
||||||
- **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale, biometric toggle survive app restart.
|
- **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale survive app restart.
|
||||||
- Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora").
|
- Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora").
|
||||||
- Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System").
|
- Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System").
|
||||||
- On login + on each foreground: `KaizenApi.fetchMe()` → `MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email.
|
- 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.
|
- **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.
|
- No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local.
|
||||||
- **Security section** (`SecuritySection.kt`): Biometric toggle (enabled/disabled, persisted in SecureStore), signed-in devices (list + remote revoke via `GET/DELETE /api/v1/auth/tokens`), password change (`POST /api/v1/auth/change-password`). `requestBiometricOrPassword()` in ChatScreen respects the biometric toggle — when disabled, always falls back to password dialog.
|
|
||||||
|
|
||||||
### Sentinel parsing — reasoning, sources, tools (added 2026-06-21)
|
### Sentinel parsing — reasoning, sources, tools (added 2026-06-21)
|
||||||
|
|
||||||
|
|
@ -228,7 +227,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
| `f3d8154` | **Sidebar redesign** — GlassSurface opacity 0.92/0.88, flat conversation rows, 3-dot context menu (rename/delete/pin/lock via `PATCH`/`DELETE /api/v1/conversations`), smaller avatar (30dp), logout integrated into user card |
|
| `f3d8154` | **Sidebar redesign** — GlassSurface opacity 0.92/0.88, flat conversation rows, 3-dot context menu (rename/delete/pin/lock via `PATCH`/`DELETE /api/v1/conversations`), smaller avatar (30dp), logout integrated into user card |
|
||||||
| `f3d8154` | **Markdown typography overhaul** — line-height 23→26sp (×1.625), paragraph spacing 6→12dp, heading margins 18dp/8dp, list spacing doubled. Matches Gemini readability |
|
| `f3d8154` | **Markdown typography overhaul** — line-height 23→26sp (×1.625), paragraph spacing 6→12dp, heading margins 18dp/8dp, list spacing doubled. Matches Gemini readability |
|
||||||
| `532cb8c` | **Reasoning + sources persistence** — saved to server (`SaveMessage.reasoning/sources`), loaded back (`StoredMessage`), cached in Room (`MessageEntity` + `SearchSource` TypeConverter). Reasoning rendered above content, sources below |
|
| `532cb8c` | **Reasoning + sources persistence** — saved to server (`SaveMessage.reasoning/sources`), loaded back (`StoredMessage`), cached in Room (`MessageEntity` + `SearchSource` TypeConverter). Reasoning rendered above content, sources below |
|
||||||
| `88559bf` | **Biometric unlock for locked conversations** — Android KeyStore AES key with `setUserAuthenticationRequired(true)` + CryptoObject, hardware-enforced. Server unlock token via `POST /api/v1/auth/unlock` + `X-Unlock-Token` header. Auto-lock on app background. Password fallback dialog (`PasswordUnlockDialog`) when biometrics unavailable — server verifies password via Argon2id. All unlock paths (open chat, sidebar toggle) gated behind `requestBiometricOrPassword()` |
|
| `88559bf` | **Biometric unlock for locked conversations** — Android KeyStore AES key with `setUserAuthenticationRequired(true)` + CryptoObject, hardware-enforced. Server unlock token via `POST /api/v1/auth/unlock` + `X-Unlock-Token` header. Auto-lock on app background |
|
||||||
| `30d3f28` | **Search mode functional** — `webSearch: true` in ChatRequest triggers agentive web search. Sources/query parsed by StreamConsumer, rendered in collapsible SourcesBlock with clickable links (ACTION_VIEW) |
|
| `30d3f28` | **Search mode functional** — `webSearch: true` in ChatRequest triggers agentive web search. Sources/query parsed by StreamConsumer, rendered in collapsible SourcesBlock with clickable links (ACTION_VIEW) |
|
||||||
| `f6dc5db` | **Reasoning presets + sampling controls** — 8-preset dropdown (Sofort→Maximal) sends `reasoningEffort`/`preferThroughput`. Sampling popover with per-parameter toggle + slider for Temperature/Top-P/Top-K |
|
| `f6dc5db` | **Reasoning presets + sampling controls** — 8-preset dropdown (Sofort→Maximal) sends `reasoningEffort`/`preferThroughput`. Sampling popover with per-parameter toggle + slider for Temperature/Top-P/Top-K |
|
||||||
| `3ca1d2c` | **Token counter** — parses usage sentinel from stream (`inputTokens`/`outputTokens`), shows ⚡ badge in top bar. Sums tokens from server messages for loaded chats. Context length from model's `context_length` field |
|
| `3ca1d2c` | **Token counter** — parses usage sentinel from stream (`inputTokens`/`outputTokens`), shows ⚡ badge in top bar. Sums tokens from server messages for loaded chats. Context length from model's `context_length` field |
|
||||||
|
|
@ -260,8 +259,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo
|
||||||
|
|
||||||
- **Room crash on launch** — schema hash mismatch after adding `reasoning`/`sources` fields to `MessageEntity` with version still at 1. Fix: bump to version 2, `fallbackToDestructiveMigration(true)`.
|
- **Room crash on launch** — schema hash mismatch after adding `reasoning`/`sources` fields to `MessageEntity` with version still at 1. Fix: bump to version 2, `fallbackToDestructiveMigration(true)`.
|
||||||
- **Keyboard handling** — `imePadding()` on Compose Box/Column caused input field to float to top of screen OR be hidden behind keyboard. Fix: read `WindowInsets.ime.getBottom()` directly, apply as negative Y `offset()` on the bottom dock. `windowSoftInputMode=adjustNothing` in manifest.
|
- **Keyboard handling** — `imePadding()` on Compose Box/Column caused input field to float to top of screen OR be hidden behind keyboard. Fix: read `WindowInsets.ime.getBottom()` directly, apply as negative Y `offset()` on the bottom dock. `windowSoftInputMode=adjustNothing` in manifest.
|
||||||
- **Locked chats not opening** — `context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain.
|
- **Locked chats not opening** — `context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain. Added fallback when biometrics unavailable.
|
||||||
- **Locked chats bypassed biometric auth** (06-22) — three paths in `openConversation()` called `unlockAndOpen()` without any authentication when biometrics were unavailable (FragmentActivity null, `isAvailable()` false, `Result.NotAvailable`). Sidebar `ToggleLock` also sent `PATCH { locked: false }` without auth. Fix: unified `requestBiometricOrPassword()` gates all unlock paths; `PasswordUnlockDialog` as fallback when biometrics unavailable; server `POST /api/v1/auth/unlock` now optionally verifies password (Argon2id) when `{ password }` provided.
|
|
||||||
- **Error banner stuck** — `loadError` never cleared after unlock failure. Fix: auto-clear after 4s, reset on navigation.
|
- **Error banner stuck** — `loadError` never cleared after unlock failure. Fix: auto-clear after 4s, reset on navigation.
|
||||||
- **Reasoning/sources lost** — not persisted to server or Room cache. Fix: added fields to `SaveMessage`, `StoredMessage`, `MessageEntity`, `Converters`, `Mappers`.
|
- **Reasoning/sources lost** — not persisted to server or Room cache. Fix: added fields to `SaveMessage`, `StoredMessage`, `MessageEntity`, `Converters`, `Mappers`.
|
||||||
- **Reasoning at bottom** — rendered below content like sources. Fix: moved above content in `ChatComponents`.
|
- **Reasoning at bottom** — rendered below content like sources. Fix: moved above content in `ChatComponents`.
|
||||||
|
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
package dev.kaizen.app.auth
|
|
||||||
|
|
||||||
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.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
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.draw.shadow
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import dev.kaizen.app.R
|
|
||||||
import dev.kaizen.app.ui.effect.GlassSurface
|
|
||||||
import dev.kaizen.app.ui.effect.GlassTiers
|
|
||||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
|
||||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PasswordUnlockDialog(
|
|
||||||
onSubmit: (password: String) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
error: String? = null,
|
|
||||||
) {
|
|
||||||
var password by remember { mutableStateOf("") }
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
|
||||||
|
|
||||||
val isDark = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
|
||||||
GlassSurface(
|
|
||||||
shape = KaizenShapes.xl,
|
|
||||||
tintAlpha = GlassTiers.card(isDark),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(24.dp)) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.unlock_password_title),
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(6.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.unlock_password_subtitle),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(20.dp))
|
|
||||||
|
|
||||||
PasswordField(
|
|
||||||
value = password,
|
|
||||||
onValueChange = { password = it },
|
|
||||||
onImeDone = { if (password.isNotBlank()) onSubmit(password) },
|
|
||||||
modifier = Modifier.focusRequester(focusRequester),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (error != null) {
|
|
||||||
Spacer(Modifier.size(10.dp))
|
|
||||||
Text(error, color = Color(0xFFEF4444), fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.size(20.dp))
|
|
||||||
|
|
||||||
Row(Modifier.fillMaxWidth()) {
|
|
||||||
DialogButton(
|
|
||||||
label = stringResource(R.string.unlock_password_cancel),
|
|
||||||
enabled = true,
|
|
||||||
primary = false,
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
DialogButton(
|
|
||||||
label = stringResource(R.string.unlock_password_submit),
|
|
||||||
enabled = password.isNotBlank(),
|
|
||||||
primary = true,
|
|
||||||
onClick = { onSubmit(password) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PasswordField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
onImeDone: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
val isDark = isSystemInDarkTheme()
|
|
||||||
val accent = LocalKaizenAccent.current
|
|
||||||
|
|
||||||
val glassBg = if (isDark) {
|
|
||||||
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.72f), Color(0xFF0F172A).copy(alpha = 0.85f)))
|
|
||||||
} else {
|
|
||||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.90f), Color(0xFFEEF2F6).copy(alpha = 0.80f)))
|
|
||||||
}
|
|
||||||
val glassBorder = 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)))
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.shadow(elevation = 4.dp, shape = KaizenShapes.md, clip = false)
|
|
||||||
.clip(KaizenShapes.md)
|
|
||||||
.background(glassBg)
|
|
||||||
.border(1.dp, glassBorder, KaizenShapes.md)
|
|
||||||
.heightIn(min = 48.dp)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.unlock_password_placeholder),
|
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
fontSize = 15.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 15.sp),
|
|
||||||
cursorBrush = SolidColor(accent.primary),
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { onImeDone() }),
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DialogButton(
|
|
||||||
label: String,
|
|
||||||
enabled: Boolean,
|
|
||||||
primary: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
val accent = LocalKaizenAccent.current
|
|
||||||
val fill = if (primary && enabled) {
|
|
||||||
Modifier.background(Brush.linearGradient(listOf(accent.primary, accent.primaryDeep)))
|
|
||||||
} else {
|
|
||||||
Modifier.background(cs.surface.copy(alpha = 0.6f))
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier
|
|
||||||
.heightIn(min = 44.dp)
|
|
||||||
.shadow(elevation = if (primary) 4.dp else 2.dp, shape = KaizenShapes.md, clip = false)
|
|
||||||
.clip(KaizenShapes.md)
|
|
||||||
.then(fill)
|
|
||||||
.clickable(enabled = enabled, onClick = onClick)
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
color = if (primary && enabled) accent.onPrimary else cs.onSurfaceVariant,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -134,11 +134,6 @@ fun ChatScreen(
|
||||||
|
|
||||||
var loadError by remember { mutableStateOf<String?>(null) }
|
var loadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Password unlock dialog state — shown when biometrics unavailable for locked chats
|
|
||||||
var passwordUnlockTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
|
||||||
var passwordUnlockError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var passwordUnlockForToggle by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Pending file attachments — uploaded immediately on pick, cleared on send.
|
// Pending file attachments — uploaded immediately on pick, cleared on send.
|
||||||
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
|
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -234,23 +229,16 @@ fun ChatScreen(
|
||||||
chatModel = null
|
chatModel = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
|
fun unlockAndOpen(summary: ConversationSummary) {
|
||||||
val cfg = session.config ?: return
|
val cfg = session.config ?: return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
loadError = null
|
loadError = null
|
||||||
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
|
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
|
||||||
if (unlockToken == null) {
|
if (unlockToken == null) {
|
||||||
if (password != null) {
|
loadError = context.getString(R.string.biometric_failed)
|
||||||
passwordUnlockError = context.getString(R.string.unlock_password_wrong)
|
scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null }
|
||||||
} else {
|
|
||||||
loadError = context.getString(R.string.biometric_failed)
|
|
||||||
scope.launch { kotlinx.coroutines.delay(4000); if (loadError == context.getString(R.string.biometric_failed)) loadError = null }
|
|
||||||
}
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
passwordUnlockTarget = null
|
|
||||||
passwordUnlockError = null
|
|
||||||
passwordUnlockForToggle = false
|
|
||||||
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
|
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
|
||||||
if (stored.isNotEmpty()) {
|
if (stored.isNotEmpty()) {
|
||||||
messages.clear()
|
messages.clear()
|
||||||
|
|
@ -273,46 +261,35 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showPasswordFallback(summary: ConversationSummary, forToggle: Boolean = false) {
|
|
||||||
passwordUnlockTarget = summary
|
|
||||||
passwordUnlockError = null
|
|
||||||
passwordUnlockForToggle = forToggle
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestBiometricOrPassword(
|
|
||||||
summary: ConversationSummary,
|
|
||||||
forToggle: Boolean = false,
|
|
||||||
onBiometricSuccess: () -> Unit,
|
|
||||||
) {
|
|
||||||
val biometricEnabled = settingsViewModel.biometricEnabled.value
|
|
||||||
var activity: androidx.fragment.app.FragmentActivity? = null
|
|
||||||
var ctx = context
|
|
||||||
while (ctx is android.content.ContextWrapper) {
|
|
||||||
if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break }
|
|
||||||
ctx = ctx.baseContext
|
|
||||||
}
|
|
||||||
if (!biometricEnabled || activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
|
|
||||||
showPasswordFallback(summary, forToggle)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dev.kaizen.app.auth.BiometricUnlock.authenticate(
|
|
||||||
activity = activity,
|
|
||||||
title = context.getString(R.string.biometric_title),
|
|
||||||
subtitle = context.getString(R.string.biometric_subtitle),
|
|
||||||
negativeButtonText = context.getString(R.string.biometric_cancel),
|
|
||||||
) { result ->
|
|
||||||
when (result) {
|
|
||||||
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> onBiometricSuccess()
|
|
||||||
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> showPasswordFallback(summary, forToggle)
|
|
||||||
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun openConversation(summary: ConversationSummary) {
|
fun openConversation(summary: ConversationSummary) {
|
||||||
val cfg = session.config ?: return
|
val cfg = session.config ?: return
|
||||||
if (summary.locked) {
|
if (summary.locked) {
|
||||||
requestBiometricOrPassword(summary) { unlockAndOpen(summary) }
|
var activity: androidx.fragment.app.FragmentActivity? = null
|
||||||
|
var ctx = context
|
||||||
|
while (ctx is android.content.ContextWrapper) {
|
||||||
|
if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break }
|
||||||
|
ctx = ctx.baseContext
|
||||||
|
}
|
||||||
|
if (activity == null) {
|
||||||
|
unlockAndOpen(summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
|
||||||
|
unlockAndOpen(summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dev.kaizen.app.auth.BiometricUnlock.authenticate(
|
||||||
|
activity = activity,
|
||||||
|
title = context.getString(R.string.biometric_title),
|
||||||
|
subtitle = context.getString(R.string.biometric_subtitle),
|
||||||
|
negativeButtonText = context.getString(R.string.biometric_cancel),
|
||||||
|
) { result ->
|
||||||
|
when (result) {
|
||||||
|
is dev.kaizen.app.auth.BiometricUnlock.Result.Success -> unlockAndOpen(summary)
|
||||||
|
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> unlockAndOpen(summary)
|
||||||
|
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewingLockedChat = false
|
viewingLockedChat = false
|
||||||
|
|
@ -503,7 +480,6 @@ fun ChatScreen(
|
||||||
AppScreen.Settings -> {
|
AppScreen.Settings -> {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
viewModel = settingsViewModel,
|
viewModel = settingsViewModel,
|
||||||
config = session.config,
|
|
||||||
onBack = { currentScreen = AppScreen.Chat },
|
onBack = { currentScreen = AppScreen.Chat },
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
|
@ -558,22 +534,8 @@ fun ChatScreen(
|
||||||
refreshConversations()
|
refreshConversations()
|
||||||
}
|
}
|
||||||
is SidebarAction.ToggleLock -> {
|
is SidebarAction.ToggleLock -> {
|
||||||
if (action.locked) {
|
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked))
|
||||||
// Locking — no auth needed
|
refreshConversations()
|
||||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
|
|
||||||
refreshConversations()
|
|
||||||
} else {
|
|
||||||
// Unlocking — require biometric or password
|
|
||||||
val summary = conversations.find { it.id == action.id }
|
|
||||||
if (summary != null) {
|
|
||||||
requestBiometricOrPassword(summary, forToggle = true) {
|
|
||||||
scope.launch {
|
|
||||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to false))
|
|
||||||
refreshConversations()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -838,38 +800,6 @@ fun ChatScreen(
|
||||||
onDismiss = { showModelSheet = false },
|
onDismiss = { showModelSheet = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password fallback dialog for locked conversations
|
|
||||||
val pwTarget = passwordUnlockTarget
|
|
||||||
if (pwTarget != null) {
|
|
||||||
dev.kaizen.app.auth.PasswordUnlockDialog(
|
|
||||||
onSubmit = { password ->
|
|
||||||
val cfg = session.config ?: return@PasswordUnlockDialog
|
|
||||||
if (passwordUnlockForToggle) {
|
|
||||||
scope.launch {
|
|
||||||
val token = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
|
|
||||||
if (token == null) {
|
|
||||||
passwordUnlockError = context.getString(R.string.unlock_password_wrong)
|
|
||||||
} else {
|
|
||||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, pwTarget.id, mapOf("locked" to false))
|
|
||||||
refreshConversations()
|
|
||||||
passwordUnlockTarget = null
|
|
||||||
passwordUnlockError = null
|
|
||||||
passwordUnlockForToggle = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unlockAndOpen(pwTarget, password)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
passwordUnlockTarget = null
|
|
||||||
passwordUnlockError = null
|
|
||||||
passwordUnlockForToggle = false
|
|
||||||
},
|
|
||||||
error = passwordUnlockError,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,21 +63,10 @@ data class ConversationSummary(
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
|
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
|
||||||
@Serializable private data class UnlockRequest(val password: String? = null)
|
|
||||||
@Serializable private data class UnlockResponse(val unlockToken: String = "")
|
@Serializable private data class UnlockResponse(val unlockToken: String = "")
|
||||||
@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)
|
||||||
|
|
||||||
@Serializable data class AppDevice(
|
|
||||||
val id: String,
|
|
||||||
val name: String = "",
|
|
||||||
val lastUsedAt: String? = null,
|
|
||||||
val createdAt: String = "",
|
|
||||||
val isCurrent: Boolean = false,
|
|
||||||
)
|
|
||||||
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList())
|
|
||||||
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
|
|
||||||
|
|
||||||
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
|
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Attachment(
|
data class Attachment(
|
||||||
|
|
@ -382,12 +371,11 @@ object KaizenApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */
|
/** POST /api/v1/auth/unlock — get a short-lived unlock token for locked conversations. */
|
||||||
suspend fun requestUnlock(baseUrl: String, token: String, password: String? = null): String? =
|
suspend fun requestUnlock(baseUrl: String, token: String): String? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val body = json.encodeToString(UnlockRequest(password))
|
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url("$baseUrl/api/v1/auth/unlock")
|
.url("$baseUrl/api/v1/auth/unlock")
|
||||||
.post(body.toRequestBody(jsonMedia))
|
.post("{}".toRequestBody(jsonMedia))
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
|
|
@ -575,63 +563,4 @@ object KaizenApi {
|
||||||
// best-effort — a failed prewarm just means the first real request pays the cold cost
|
// best-effort — a failed prewarm just means the first real request pays the cold cost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/v1/auth/tokens — list the user's app devices/tokens. */
|
|
||||||
suspend fun fetchDevices(baseUrl: String, token: String): List<AppDevice> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/auth/tokens")
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return@use emptyList()
|
|
||||||
json.decodeFromString<AppDevicesResponse>(resp.body!!.string()).tokens
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "fetchDevices: ${e.javaClass.simpleName}: ${e.message}")
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DELETE /api/v1/auth/tokens/[id] — revoke a device token (remote logout). */
|
|
||||||
suspend fun revokeDevice(baseUrl: String, token: String, deviceId: String): Boolean =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/auth/tokens/$deviceId")
|
|
||||||
.delete()
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { it.isSuccessful }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "revokeDevice: ${e.javaClass.simpleName}: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/auth/change-password — change the user's password. */
|
|
||||||
suspend fun changePassword(baseUrl: String, token: String, currentPassword: String, newPassword: String): FetchResult<Unit> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val body = json.encodeToString(ChangePasswordRequest(currentPassword, newPassword))
|
|
||||||
val req = Request.Builder()
|
|
||||||
.url("$baseUrl/api/v1/auth/change-password")
|
|
||||||
.post(body.toRequestBody(jsonMedia))
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(req).execute().use { resp ->
|
|
||||||
if (resp.isSuccessful) FetchResult.Ok(Unit)
|
|
||||||
else {
|
|
||||||
val errBody = resp.body?.string() ?: ""
|
|
||||||
val error = try { json.decodeFromString<ErrorBody>(errBody).error } catch (_: Exception) { "unknown" }
|
|
||||||
FetchResult.Fail(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
FetchResult.Fail(e.message ?: "network_error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable private data class ErrorBody(val error: String = "unknown")
|
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,6 @@ class SecureStore(context: Context) {
|
||||||
fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null)
|
fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null)
|
||||||
fun saveLocale(locale: String) { prefs.edit().putString(KEY_LOCALE, locale).apply() }
|
fun saveLocale(locale: String) { prefs.edit().putString(KEY_LOCALE, locale).apply() }
|
||||||
|
|
||||||
fun loadBiometricEnabled(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ENABLED, true)
|
|
||||||
fun saveBiometricEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, enabled).apply() }
|
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
prefs.edit().clear().apply()
|
prefs.edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +107,5 @@ class SecureStore(context: Context) {
|
||||||
const val KEY_APPEARANCE = "appearance"
|
const val KEY_APPEARANCE = "appearance"
|
||||||
const val KEY_USER_NAME = "user_name"
|
const val KEY_USER_NAME = "user_name"
|
||||||
const val KEY_LOCALE = "locale"
|
const val KEY_LOCALE = "locale"
|
||||||
const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
package dev.kaizen.app.settings
|
|
||||||
|
|
||||||
import androidx.biometric.BiometricManager
|
|
||||||
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.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Fingerprint
|
|
||||||
import androidx.compose.material.icons.rounded.Key
|
|
||||||
import androidx.compose.material.icons.rounded.PhoneAndroid
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
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.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import dev.kaizen.app.R
|
|
||||||
import dev.kaizen.app.net.AppDevice
|
|
||||||
import dev.kaizen.app.net.ServerConfig
|
|
||||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
|
||||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SecuritySection(
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
config: ServerConfig?,
|
|
||||||
) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
val accent = LocalKaizenAccent.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val biometricEnabled by viewModel.biometricEnabled.collectAsState()
|
|
||||||
val devices by viewModel.devices.collectAsState()
|
|
||||||
val devicesLoading by viewModel.devicesLoading.collectAsState()
|
|
||||||
val passwordResult by viewModel.passwordChangeResult.collectAsState()
|
|
||||||
|
|
||||||
val biometricAvailable = remember {
|
|
||||||
val bm = BiometricManager.from(context)
|
|
||||||
bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
|
|
||||||
}
|
|
||||||
|
|
||||||
val biometricNotEnrolled = remember {
|
|
||||||
val bm = BiometricManager.from(context)
|
|
||||||
bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
|
||||||
config?.let { viewModel.loadDevices(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.settings_section_security),
|
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Biometric toggle
|
|
||||||
SettingsCard(modifier = Modifier.padding(bottom = 16.dp)) {
|
|
||||||
SettingsOptionRow(
|
|
||||||
title = stringResource(R.string.settings_biometric_title),
|
|
||||||
description = if (biometricNotEnrolled) stringResource(R.string.settings_biometric_not_enrolled)
|
|
||||||
else stringResource(R.string.settings_biometric_desc),
|
|
||||||
icon = Icons.Rounded.Fingerprint,
|
|
||||||
) {
|
|
||||||
Switch(
|
|
||||||
checked = biometricEnabled && biometricAvailable,
|
|
||||||
onCheckedChange = { viewModel.setBiometricEnabled(it) },
|
|
||||||
enabled = biometricAvailable,
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = accent.onPrimary,
|
|
||||||
checkedTrackColor = accent.primary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Devices
|
|
||||||
SettingsCard(modifier = Modifier.padding(bottom = 16.dp)) {
|
|
||||||
SettingsOptionRow(
|
|
||||||
title = stringResource(R.string.settings_devices_title),
|
|
||||||
description = stringResource(R.string.settings_devices_desc),
|
|
||||||
icon = Icons.Rounded.PhoneAndroid,
|
|
||||||
) {
|
|
||||||
if (devicesLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color = accent.primary,
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
devices.forEach { device ->
|
|
||||||
DeviceRow(
|
|
||||||
device = device,
|
|
||||||
onRevoke = { config?.let { viewModel.revokeDevice(it, device.id) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (devices.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"–",
|
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change password
|
|
||||||
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
|
|
||||||
SettingsOptionRow(
|
|
||||||
title = stringResource(R.string.settings_password_title),
|
|
||||||
description = "",
|
|
||||||
icon = Icons.Rounded.Key,
|
|
||||||
) {
|
|
||||||
PasswordChangeForm(
|
|
||||||
onSubmit = { current, new_ ->
|
|
||||||
config?.let { viewModel.changePassword(it, current, new_) }
|
|
||||||
},
|
|
||||||
result = passwordResult,
|
|
||||||
onClearResult = { viewModel.clearPasswordResult() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DeviceRow(device: AppDevice, onRevoke: () -> Unit) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
val accent = LocalKaizenAccent.current
|
|
||||||
val locale = java.util.Locale.getDefault()
|
|
||||||
|
|
||||||
val lastUsedText = remember(device.lastUsedAt) {
|
|
||||||
if (device.lastUsedAt != null) {
|
|
||||||
try {
|
|
||||||
val date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", locale).parse(device.lastUsedAt)
|
|
||||||
?: java.util.Date(device.lastUsedAt.toLongOrNull() ?: 0)
|
|
||||||
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM, locale).format(date)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
device.lastUsedAt
|
|
||||||
}
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(KaizenShapes.md)
|
|
||||||
.background(cs.surface.copy(alpha = 0.4f))
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
device.name.ifBlank { "App" },
|
|
||||||
color = cs.onBackground,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
if (device.isCurrent) {
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.settings_device_current),
|
|
||||||
color = accent.primary,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
if (lastUsedText != null) stringResource(R.string.settings_device_last_used, lastUsedText)
|
|
||||||
else stringResource(R.string.settings_device_never_used),
|
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!device.isCurrent) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.settings_device_revoke),
|
|
||||||
color = Color(0xFFEF4444),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(KaizenShapes.sm)
|
|
||||||
.clickable { onRevoke() }
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PasswordChangeForm(
|
|
||||||
onSubmit: (currentPassword: String, newPassword: String) -> Unit,
|
|
||||||
result: String?,
|
|
||||||
onClearResult: () -> Unit,
|
|
||||||
) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
val accent = LocalKaizenAccent.current
|
|
||||||
|
|
||||||
var current by remember { mutableStateOf("") }
|
|
||||||
var new_ by remember { mutableStateOf("") }
|
|
||||||
var confirm by remember { mutableStateOf("") }
|
|
||||||
var localError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val mismatchMsg = stringResource(R.string.settings_password_error_mismatch)
|
|
||||||
val shortMsg = stringResource(R.string.settings_password_error_short)
|
|
||||||
val successMsg = stringResource(R.string.settings_password_success)
|
|
||||||
val wrongMsg = stringResource(R.string.settings_password_error_wrong)
|
|
||||||
val unknownMsg = stringResource(R.string.settings_password_error_unknown)
|
|
||||||
|
|
||||||
LaunchedEffect(result) {
|
|
||||||
when (result) {
|
|
||||||
"ok" -> {
|
|
||||||
current = ""; new_ = ""; confirm = ""; localError = null
|
|
||||||
kotlinx.coroutines.delay(2000)
|
|
||||||
onClearResult()
|
|
||||||
}
|
|
||||||
"invalid_password" -> { localError = wrongMsg; onClearResult() }
|
|
||||||
"password_too_short" -> { localError = shortMsg; onClearResult() }
|
|
||||||
null -> {}
|
|
||||||
else -> { localError = unknownMsg; onClearResult() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
PasswordField(value = current, onValueChange = { current = it; localError = null }, placeholder = stringResource(R.string.settings_password_current))
|
|
||||||
PasswordField(value = new_, onValueChange = { new_ = it; localError = null }, placeholder = stringResource(R.string.settings_password_new))
|
|
||||||
PasswordField(value = confirm, onValueChange = { confirm = it; localError = null }, placeholder = stringResource(R.string.settings_password_confirm), imeAction = ImeAction.Done, onImeDone = {
|
|
||||||
if (new_.length >= 8 && new_ == confirm && current.isNotEmpty()) onSubmit(current, new_)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (localError != null) {
|
|
||||||
Text(localError!!, color = Color(0xFFEF4444), fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
if (result == "ok") {
|
|
||||||
Text(successMsg, color = accent.primary, fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
|
|
||||||
val canSubmit = current.isNotEmpty() && new_.length >= 8 && new_ == confirm
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 40.dp)
|
|
||||||
.shadow(elevation = if (canSubmit) 4.dp else 2.dp, shape = KaizenShapes.md, clip = false)
|
|
||||||
.clip(KaizenShapes.md)
|
|
||||||
.background(
|
|
||||||
if (canSubmit) Brush.linearGradient(listOf(accent.primary, accent.primaryDeep))
|
|
||||||
else Brush.linearGradient(listOf(cs.surface, cs.surface))
|
|
||||||
)
|
|
||||||
.clickable(enabled = canSubmit) {
|
|
||||||
if (new_ != confirm) { localError = mismatchMsg; return@clickable }
|
|
||||||
if (new_.length < 8) { localError = shortMsg; return@clickable }
|
|
||||||
onSubmit(current, new_)
|
|
||||||
}
|
|
||||||
.padding(vertical = 10.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.settings_password_submit),
|
|
||||||
color = if (canSubmit) accent.onPrimary else cs.onSurfaceVariant,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PasswordField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
imeAction: ImeAction = ImeAction.Next,
|
|
||||||
onImeDone: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val cs = MaterialTheme.colorScheme
|
|
||||||
val isDark = isSystemInDarkTheme()
|
|
||||||
val accent = LocalKaizenAccent.current
|
|
||||||
|
|
||||||
val bg = if (isDark) {
|
|
||||||
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.60f), Color(0xFF0F172A).copy(alpha = 0.72f)))
|
|
||||||
} else {
|
|
||||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFEEF2F6).copy(alpha = 0.75f)))
|
|
||||||
}
|
|
||||||
val border = if (isDark) {
|
|
||||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.White.copy(alpha = 0.03f)))
|
|
||||||
} else {
|
|
||||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.05f)))
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(KaizenShapes.md)
|
|
||||||
.background(bg)
|
|
||||||
.border(1.dp, border, KaizenShapes.md)
|
|
||||||
.heightIn(min = 44.dp)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
Text(placeholder, color = cs.onSurfaceVariant.copy(alpha = 0.5f), fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 14.sp),
|
|
||||||
cursorBrush = SolidColor(accent.primary),
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { onImeDone() }),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -57,7 +57,6 @@ import dev.kaizen.app.chat.MeshBackground
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
config: dev.kaizen.app.net.ServerConfig? = null,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -150,11 +149,9 @@ fun SettingsScreen(
|
||||||
|
|
||||||
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(stringResource(R.string.settings_section_ai), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp))
|
||||||
|
|
||||||
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
|
SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) {
|
||||||
SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome)
|
SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecuritySection(viewModel = viewModel, config = config)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
package dev.kaizen.app.settings
|
package dev.kaizen.app.settings
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dev.kaizen.app.net.AppDevice
|
|
||||||
import dev.kaizen.app.net.FetchResult
|
|
||||||
import dev.kaizen.app.net.KaizenApi
|
|
||||||
import dev.kaizen.app.net.SecureStore
|
import dev.kaizen.app.net.SecureStore
|
||||||
import dev.kaizen.app.net.ServerConfig
|
|
||||||
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
|
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
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
enum class AppAppearance { Light, Dark, System }
|
enum class AppAppearance { Light, Dark, System }
|
||||||
|
|
||||||
|
|
@ -75,51 +69,4 @@ class SettingsViewModel(private val store: SecureStore? = null) : ViewModel() {
|
||||||
activity?.recreate()
|
activity?.recreate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Security ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val _biometricEnabled = MutableStateFlow(store?.loadBiometricEnabled() ?: true)
|
|
||||||
val biometricEnabled: StateFlow<Boolean> = _biometricEnabled.asStateFlow()
|
|
||||||
|
|
||||||
private val _devices = MutableStateFlow<List<AppDevice>>(emptyList())
|
|
||||||
val devices: StateFlow<List<AppDevice>> = _devices.asStateFlow()
|
|
||||||
|
|
||||||
private val _devicesLoading = MutableStateFlow(false)
|
|
||||||
val devicesLoading: StateFlow<Boolean> = _devicesLoading.asStateFlow()
|
|
||||||
|
|
||||||
private val _passwordChangeResult = MutableStateFlow<String?>(null)
|
|
||||||
val passwordChangeResult: StateFlow<String?> = _passwordChangeResult.asStateFlow()
|
|
||||||
|
|
||||||
fun setBiometricEnabled(enabled: Boolean) {
|
|
||||||
_biometricEnabled.value = enabled
|
|
||||||
store?.saveBiometricEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadDevices(config: ServerConfig) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_devicesLoading.value = true
|
|
||||||
_devices.value = KaizenApi.fetchDevices(config.baseUrl, config.token)
|
|
||||||
_devicesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun revokeDevice(config: ServerConfig, deviceId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (KaizenApi.revokeDevice(config.baseUrl, config.token, deviceId)) {
|
|
||||||
_devices.value = _devices.value.filter { it.id != deviceId }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun changePassword(config: ServerConfig, currentPassword: String, newPassword: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_passwordChangeResult.value = null
|
|
||||||
when (val r = KaizenApi.changePassword(config.baseUrl, config.token, currentPassword, newPassword)) {
|
|
||||||
is FetchResult.Ok -> _passwordChangeResult.value = "ok"
|
|
||||||
is FetchResult.Fail -> _passwordChangeResult.value = r.reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearPasswordResult() { _passwordChangeResult.value = null }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,16 @@
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<!-- Dark mode: deep obsidian with subtle warm center glow -->
|
<!-- Dark mode: Obsidian with subtle blue steel gradient -->
|
||||||
<path android:pathData="M0,0h108v108h-108z">
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
<aapt:attr name="android:fillColor">
|
<aapt:attr name="android:fillColor">
|
||||||
<gradient
|
<gradient
|
||||||
android:type="radial"
|
android:type="radial"
|
||||||
android:centerX="54"
|
android:centerX="54"
|
||||||
android:centerY="50"
|
android:centerY="54"
|
||||||
android:gradientRadius="72">
|
android:gradientRadius="76">
|
||||||
<item android:color="#FF141C28" android:offset="0.0" />
|
<item android:color="#FF111824" android:offset="0.0" />
|
||||||
<item android:color="#FF0E1420" android:offset="0.5" />
|
<item android:color="#FF0A0E16" android:offset="1.0" />
|
||||||
<item android:color="#FF080C14" android:offset="1.0" />
|
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,16 @@
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<!-- Light mode: warm cream with subtle vignette -->
|
<!-- Light mode: warm off-white with subtle amber tint -->
|
||||||
<path android:pathData="M0,0h108v108h-108z">
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
<aapt:attr name="android:fillColor">
|
<aapt:attr name="android:fillColor">
|
||||||
<gradient
|
<gradient
|
||||||
android:type="radial"
|
android:type="radial"
|
||||||
android:centerX="54"
|
android:centerX="54"
|
||||||
android:centerY="50"
|
android:centerY="54"
|
||||||
android:gradientRadius="72">
|
android:gradientRadius="76">
|
||||||
<item android:color="#FFF9F3EB" android:offset="0.0" />
|
<item android:color="#FFF8F4EE" android:offset="0.0" />
|
||||||
<item android:color="#FFF4ECE0" android:offset="0.5" />
|
<item android:color="#FFF1EBE3" android:offset="1.0" />
|
||||||
<item android:color="#FFEAE0D0" android:offset="1.0" />
|
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
|
||||||
|
|
@ -6,183 +6,86 @@
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
|
|
||||||
<!-- L0: Soft outer halo — large, diffuse amber glow -->
|
<!-- Soft ambient glow behind the orb -->
|
||||||
<path android:pathData="M54,54m-34,0a34,34 0,1 1,68 0a34,34 0,1 1,-68 0">
|
<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:type="radial"
|
android:type="radial"
|
||||||
android:centerX="52"
|
android:centerX="54"
|
||||||
android:centerY="50"
|
android:centerY="54"
|
||||||
android:gradientRadius="34">
|
android:gradientRadius="30">
|
||||||
<item android:color="#40D4942A" android:offset="0.0" />
|
<item android:color="#50E0A23E" android:offset="0.0" />
|
||||||
<item android:color="#20D4942A" android:offset="0.4" />
|
<item android:color="#18E0A23E" android:offset="0.6" />
|
||||||
<item android:color="#08D4942A" android:offset="0.7" />
|
|
||||||
<item android:color="#00000000" android:offset="1.0" />
|
<item android:color="#00000000" android:offset="1.0" />
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<!-- L1: Orb body — warm amber core, light source offset top-left -->
|
<!-- 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">
|
<path android:pathData="M54,54m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0">
|
||||||
<aapt:attr name="android:fillColor">
|
<aapt:attr name="android:fillColor">
|
||||||
<gradient
|
<gradient
|
||||||
android:type="radial"
|
android:type="radial"
|
||||||
android:centerX="48"
|
android:centerX="47"
|
||||||
android:centerY="46"
|
android:centerY="45"
|
||||||
android:gradientRadius="26">
|
android:gradientRadius="28">
|
||||||
<item android:color="#FFFCE8C0" android:offset="0.0" />
|
<item android:color="#FFFFFBE6" android:offset="0.0" />
|
||||||
<item android:color="#FFEAB86A" android:offset="0.28" />
|
<item android:color="#FFFFD685" android:offset="0.25" />
|
||||||
<item android:color="#FFD49430" android:offset="0.52" />
|
<item android:color="#FFE0A23E" android:offset="0.50" />
|
||||||
<item android:color="#FFBE7A20" android:offset="0.72" />
|
<item android:color="#FFC8862A" android:offset="0.75" />
|
||||||
<item android:color="#FF9A5A12" android:offset="0.88" />
|
<item android:color="#FF7A4400" android:offset="1.0" />
|
||||||
<item android:color="#FF7A4410" android:offset="1.0" />
|
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<!-- L2: Glass overlay — cool-tinted top half for lens refraction feel -->
|
<!-- 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">
|
<path android:pathData="M54,54m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0">
|
||||||
<aapt:attr name="android:fillColor">
|
<aapt:attr name="android:fillColor">
|
||||||
<gradient
|
<gradient
|
||||||
android:type="radial"
|
android:type="radial"
|
||||||
android:centerX="50"
|
android:centerX="50"
|
||||||
android:centerY="40"
|
android:centerY="48"
|
||||||
android:gradientRadius="24">
|
android:gradientRadius="22">
|
||||||
<item android:color="#18FFFFFF" android:offset="0.0" />
|
|
||||||
<item android:color="#0CFFFFFF" android:offset="0.35" />
|
|
||||||
<item android:color="#00FFFFFF" android:offset="0.65" />
|
|
||||||
<item android:color="#00000000" android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
|
|
||||||
<!-- L3: Bottom-right accent warmth (bounce light from environment) -->
|
|
||||||
<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="64"
|
|
||||||
android:centerY="66"
|
|
||||||
android:gradientRadius="14">
|
|
||||||
<item android:color="#50FFBE50" android:offset="0.0" />
|
|
||||||
<item android:color="#20FFBE50" android:offset="0.4" />
|
|
||||||
<item android:color="#00FFBE50" android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
|
|
||||||
<!-- L4: Bottom shadow — dark crescent for volume/sphere feeling -->
|
|
||||||
<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="52"
|
|
||||||
android:centerY="44"
|
|
||||||
android:gradientRadius="23">
|
|
||||||
<item android:color="#00000000" android:offset="0.0" />
|
<item android:color="#00000000" android:offset="0.0" />
|
||||||
<item android:color="#00000000" android:offset="0.55" />
|
<item android:color="#00000000" android:offset="0.50" />
|
||||||
<item android:color="#30301800" android:offset="0.78" />
|
<item android:color="#993D1E00" android:offset="1.0" />
|
||||||
<item android:color="#70301800" android:offset="0.92" />
|
|
||||||
<item android:color="#90201000" android:offset="1.0" />
|
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<!-- L5: Internal caustic structure — off-center radial for depth -->
|
<!-- Primary specular highlight — top-left, bright white gloss -->
|
||||||
<path android:pathData="M54,54m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0">
|
<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="58"
|
|
||||||
android:centerY="50"
|
|
||||||
android:gradientRadius="20">
|
|
||||||
<item android:color="#00FFFFFF" android:offset="0.0" />
|
|
||||||
<item android:color="#0AFFD890" android:offset="0.3" />
|
|
||||||
<item android:color="#14E8A030" android:offset="0.6" />
|
|
||||||
<item android:color="#00000000" android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
|
|
||||||
<!-- L6: Primary specular — tight elliptical highlight top-left -->
|
|
||||||
<path android:pathData="M45,42.5a8,6.5 -15,1 1,16 0a8,6.5 -15,1 1,-16 0">
|
|
||||||
<aapt:attr name="android:fillColor">
|
<aapt:attr name="android:fillColor">
|
||||||
<gradient
|
<gradient
|
||||||
android:type="radial"
|
android:type="radial"
|
||||||
android:centerX="46"
|
android:centerX="46"
|
||||||
android:centerY="42"
|
|
||||||
android:gradientRadius="8">
|
|
||||||
<item android:color="#D0FFFFFF" android:offset="0.0" />
|
|
||||||
<item android:color="#60FFFFFF" android:offset="0.3" />
|
|
||||||
<item android:color="#18FFFFFF" android:offset="0.6" />
|
|
||||||
<item android:color="#00FFFFFF" android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
|
|
||||||
<!-- L7: Micro specular — sharp pinpoint glint -->
|
|
||||||
<path android:pathData="M44,40m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:type="radial"
|
|
||||||
android:centerX="44"
|
|
||||||
android:centerY="40"
|
|
||||||
android:gradientRadius="2.5">
|
|
||||||
<item android:color="#F0FFFFFF" android:offset="0.0" />
|
|
||||||
<item android:color="#60FFFFFF" android:offset="0.5" />
|
|
||||||
<item android:color="#00FFFFFF" android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
|
|
||||||
<!-- L8: Secondary specular — bottom-right warm bounce -->
|
|
||||||
<path android:pathData="M63.5,64m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:type="radial"
|
|
||||||
android:centerX="63.5"
|
|
||||||
android:centerY="64"
|
|
||||||
android:gradientRadius="4">
|
|
||||||
<item android:color="#50FFD890" android:offset="0.0" />
|
|
||||||
<item android:color="#20FFD890" android:offset="0.5" />
|
|
||||||
<item android:color="#00FFD890" android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
|
|
||||||
<!-- L9: Outer rim — crisp glass edge -->
|
|
||||||
<path
|
|
||||||
android:pathData="M54,54m-20.5,0a20.5,20.5 0,1 1,41 0a20.5,20.5 0,1 1,-41 0"
|
|
||||||
android:strokeWidth="0.7"
|
|
||||||
android:fillColor="#00000000">
|
|
||||||
<aapt:attr name="android:strokeColor">
|
|
||||||
<gradient
|
|
||||||
android:type="radial"
|
|
||||||
android:centerX="46"
|
|
||||||
android:centerY="42"
|
|
||||||
android:gradientRadius="30">
|
|
||||||
<item android:color="#40FFFFFF" android:offset="0.0" />
|
|
||||||
<item android:color="#18FFFFFF" android:offset="0.5" />
|
|
||||||
<item android:color="#08FFFFFF" android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
|
|
||||||
<!-- L10: Inner rim — subtle inset edge for glass depth -->
|
|
||||||
<path
|
|
||||||
android:pathData="M54,54m-19.5,0a19.5,19.5 0,1 1,39 0a19.5,19.5 0,1 1,-39 0"
|
|
||||||
android:strokeWidth="0.4"
|
|
||||||
android:fillColor="#00000000">
|
|
||||||
<aapt:attr name="android:strokeColor">
|
|
||||||
<gradient
|
|
||||||
android:type="radial"
|
|
||||||
android:centerX="48"
|
|
||||||
android:centerY="44"
|
android:centerY="44"
|
||||||
android:gradientRadius="26">
|
android:gradientRadius="9">
|
||||||
<item android:color="#20FFFFFF" android:offset="0.0" />
|
<item android:color="#E0FFFFFF" android:offset="0.0" />
|
||||||
<item android:color="#08FFFFFF" android:offset="0.6" />
|
<item android:color="#40FFFFFF" android:offset="0.4" />
|
||||||
<item android:color="#04FFFFFF" android:offset="1.0" />
|
<item android:color="#00FFFFFF" android:offset="1.0" />
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
|
<!-- Secondary warm reflection — bottom-right bounce light -->
|
||||||
|
<path android:pathData="M63,63m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:type="radial"
|
||||||
|
android:centerX="63"
|
||||||
|
android:centerY="63"
|
||||||
|
android:gradientRadius="7">
|
||||||
|
<item android:color="#80FFD080" android:offset="0.0" />
|
||||||
|
<item android:color="#00FFD080" android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
|
||||||
|
<!-- Crisp outer rim — thin glass edge -->
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54m-20.6,0a20.6,20.6 0,1 1,41.2 0a20.6,20.6 0,1 1,-41.2 0"
|
||||||
|
android:strokeWidth="0.6"
|
||||||
|
android:strokeColor="#25FFFFFF"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
||||||
|
|
@ -6,40 +6,31 @@
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
|
|
||||||
<!-- Orb body — solid-to-fade for themed icon legibility -->
|
<!-- 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">
|
<path android:pathData="M54,54m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0">
|
||||||
<aapt:attr name="android:fillColor">
|
<aapt:attr name="android:fillColor">
|
||||||
<gradient
|
<gradient
|
||||||
android:type="radial"
|
android:type="radial"
|
||||||
android:centerX="48"
|
android:centerX="48"
|
||||||
android:centerY="44"
|
android:centerY="46"
|
||||||
android:gradientRadius="26">
|
android:gradientRadius="24">
|
||||||
<item android:color="#FFFFFFFF" android:offset="0.0" />
|
<item android:color="#FFFFFFFF" android:offset="0.0" />
|
||||||
<item android:color="#CCFFFFFF" android:offset="0.5" />
|
<item android:color="#80FFFFFF" android:offset="1.0" />
|
||||||
<item android:color="#60FFFFFF" android:offset="1.0" />
|
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<!-- Specular dot — makes it read as a sphere, not a flat circle -->
|
<!-- Specular dot -->
|
||||||
<path android:pathData="M45,43m-5,0a5,4 0,1 1,10 0a5,4 0,1 1,-10 0">
|
<path android:pathData="M54,54m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0">
|
||||||
<aapt:attr name="android:fillColor">
|
<aapt:attr name="android:fillColor">
|
||||||
<gradient
|
<gradient
|
||||||
android:type="radial"
|
android:type="radial"
|
||||||
android:centerX="45"
|
android:centerX="45"
|
||||||
android:centerY="43"
|
android:centerY="43"
|
||||||
android:gradientRadius="5">
|
android:gradientRadius="8">
|
||||||
<item android:color="#FFFFFFFF" android:offset="0.0" />
|
<item android:color="#FFFFFFFF" android:offset="0.0" />
|
||||||
<item android:color="#80FFFFFF" android:offset="0.4" />
|
|
||||||
<item android:color="#00FFFFFF" android:offset="1.0" />
|
<item android:color="#00FFFFFF" android:offset="1.0" />
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|
||||||
<!-- Rim -->
|
|
||||||
<path
|
|
||||||
android:pathData="M54,54m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#40FFFFFF"
|
|
||||||
android:fillColor="#00000000" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,6 @@
|
||||||
<string name="biometric_not_available">Biometrics not available</string>
|
<string name="biometric_not_available">Biometrics not available</string>
|
||||||
<string name="biometric_failed">Authentication failed</string>
|
<string name="biometric_failed">Authentication failed</string>
|
||||||
<string name="unlock_tap_to_unlock">Tap to unlock</string>
|
<string name="unlock_tap_to_unlock">Tap to unlock</string>
|
||||||
<string name="unlock_password_title">Unlock Chat</string>
|
|
||||||
<string name="unlock_password_subtitle">Enter your password to unlock this chat</string>
|
|
||||||
<string name="unlock_password_placeholder">Password</string>
|
|
||||||
<string name="unlock_password_submit">Unlock</string>
|
|
||||||
<string name="unlock_password_cancel">Cancel</string>
|
|
||||||
<string name="unlock_password_wrong">Wrong password</string>
|
|
||||||
|
|
||||||
<!-- Model sheet -->
|
<!-- Model sheet -->
|
||||||
<string name="model_sheet_title">Select Model</string>
|
<string name="model_sheet_title">Select Model</string>
|
||||||
|
|
@ -124,28 +118,6 @@
|
||||||
<string name="login_error_rate_limited">Too many attempts. Please wait.</string>
|
<string name="login_error_rate_limited">Too many attempts. Please wait.</string>
|
||||||
<string name="login_error_connection">Connection failed. Check server address.</string>
|
<string name="login_error_connection">Connection failed. Check server address.</string>
|
||||||
|
|
||||||
<!-- Security settings -->
|
|
||||||
<string name="settings_section_security">SECURITY</string>
|
|
||||||
<string name="settings_biometric_title">Biometric Unlock</string>
|
|
||||||
<string name="settings_biometric_desc">Unlock locked chats with fingerprint</string>
|
|
||||||
<string name="settings_biometric_not_enrolled">No fingerprint enrolled on this device</string>
|
|
||||||
<string name="settings_devices_title">Signed-in Devices</string>
|
|
||||||
<string name="settings_devices_desc">Manage app sessions and sign out remotely</string>
|
|
||||||
<string name="settings_device_current">This device</string>
|
|
||||||
<string name="settings_device_last_used">Last active: %1$s</string>
|
|
||||||
<string name="settings_device_never_used">Never used</string>
|
|
||||||
<string name="settings_device_revoke">Sign out</string>
|
|
||||||
<string name="settings_password_title">Change Password</string>
|
|
||||||
<string name="settings_password_current">Current password</string>
|
|
||||||
<string name="settings_password_new">New password</string>
|
|
||||||
<string name="settings_password_confirm">Confirm password</string>
|
|
||||||
<string name="settings_password_submit">Change Password</string>
|
|
||||||
<string name="settings_password_success">Password changed</string>
|
|
||||||
<string name="settings_password_error_wrong">Current password is incorrect</string>
|
|
||||||
<string name="settings_password_error_short">At least 8 characters</string>
|
|
||||||
<string name="settings_password_error_mismatch">Passwords don\'t match</string>
|
|
||||||
<string name="settings_password_error_unknown">Failed to change password</string>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="settings_back">Back</string>
|
<string name="settings_back">Back</string>
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,6 @@
|
||||||
<string name="biometric_not_available">Biometrie nicht verfügbar</string>
|
<string name="biometric_not_available">Biometrie nicht verfügbar</string>
|
||||||
<string name="biometric_failed">Authentifizierung fehlgeschlagen</string>
|
<string name="biometric_failed">Authentifizierung fehlgeschlagen</string>
|
||||||
<string name="unlock_tap_to_unlock">Tippen zum Entsperren</string>
|
<string name="unlock_tap_to_unlock">Tippen zum Entsperren</string>
|
||||||
<string name="unlock_password_title">Chat entsperren</string>
|
|
||||||
<string name="unlock_password_subtitle">Gib dein Passwort ein, um den Chat zu entsperren</string>
|
|
||||||
<string name="unlock_password_placeholder">Passwort</string>
|
|
||||||
<string name="unlock_password_submit">Entsperren</string>
|
|
||||||
<string name="unlock_password_cancel">Abbrechen</string>
|
|
||||||
<string name="unlock_password_wrong">Falsches Passwort</string>
|
|
||||||
|
|
||||||
<!-- Model sheet -->
|
<!-- Model sheet -->
|
||||||
<string name="model_sheet_title">Modell auswählen</string>
|
<string name="model_sheet_title">Modell auswählen</string>
|
||||||
|
|
@ -126,28 +120,6 @@
|
||||||
<string name="login_error_rate_limited">Zu viele Versuche. Bitte kurz warten.</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>
|
<string name="login_error_connection">Verbindung fehlgeschlagen. Server-Adresse prüfen.</string>
|
||||||
|
|
||||||
<!-- Security settings -->
|
|
||||||
<string name="settings_section_security">SICHERHEIT</string>
|
|
||||||
<string name="settings_biometric_title">Biometrische Entsperrung</string>
|
|
||||||
<string name="settings_biometric_desc">Gesperrte Chats per Fingerabdruck entsperren</string>
|
|
||||||
<string name="settings_biometric_not_enrolled">Kein Fingerabdruck auf dem Gerät eingerichtet</string>
|
|
||||||
<string name="settings_devices_title">Angemeldete Geräte</string>
|
|
||||||
<string name="settings_devices_desc">App-Sitzungen verwalten und abmelden</string>
|
|
||||||
<string name="settings_device_current">Dieses Gerät</string>
|
|
||||||
<string name="settings_device_last_used">Zuletzt aktiv: %1$s</string>
|
|
||||||
<string name="settings_device_never_used">Nie verwendet</string>
|
|
||||||
<string name="settings_device_revoke">Abmelden</string>
|
|
||||||
<string name="settings_password_title">Passwort ändern</string>
|
|
||||||
<string name="settings_password_current">Aktuelles Passwort</string>
|
|
||||||
<string name="settings_password_new">Neues Passwort</string>
|
|
||||||
<string name="settings_password_confirm">Passwort bestätigen</string>
|
|
||||||
<string name="settings_password_submit">Passwort ändern</string>
|
|
||||||
<string name="settings_password_success">Passwort geändert</string>
|
|
||||||
<string name="settings_password_error_wrong">Aktuelles Passwort ist falsch</string>
|
|
||||||
<string name="settings_password_error_short">Mindestens 8 Zeichen</string>
|
|
||||||
<string name="settings_password_error_mismatch">Passwörter stimmen nicht überein</string>
|
|
||||||
<string name="settings_password_error_unknown">Fehler beim Ändern</string>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<string name="settings_title">Einstellungen</string>
|
<string name="settings_title">Einstellungen</string>
|
||||||
<string name="settings_back">Zurück</string>
|
<string name="settings_back">Zurück</string>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue