Compare commits

..

No commits in common. "e62c5253868b028964194f3f0b66628c96286c16" and "f8e1747df4db2596095b8d8e339fc5ca253b179b" have entirely different histories.

14 changed files with 105 additions and 1038 deletions

View file

@ -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/`)
```
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject), PasswordUnlockDialog
auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject)
chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
ModelSheet, Markdown, Background
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
@ -43,7 +43,7 @@ db/ Room offline cache: KaizenDatabase, Entities, DAOs,
haptics/ Phase-aware haptics
net/ KaizenApi (HTTP), SecureStore (encrypted prefs),
SessionViewModel, ServerConfig, StreamConsumer
settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecuritySection
settings/ SettingsScreen, SettingsViewModel, SettingsComponents
ui/
theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither
theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry
@ -100,13 +100,12 @@ Full spec: `DESIGN.md`.
### 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").
- Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System").
- On login + on each foreground: `KaizenApi.fetchMe()``MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email.
- **Language selector** in Settings: System / Deutsch / English. Stored as `locale` in SecureStore. Note: currently only stores the preference; runtime locale switching (AppCompatDelegate.setApplicationLocales) needs wiring in MainActivity on next iteration.
- No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local.
- **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)
@ -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` | **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 |
| `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) |
| `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 |
@ -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)`.
- **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 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.
- **Locked chats not opening**`context as? FragmentActivity` silently returned null (ContextWrapper). Fix: walk ContextWrapper chain. Added fallback when biometrics unavailable.
- **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 at bottom** — rendered below content like sources. Fix: moved above content in `ChatComponents`.

View file

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

View file

@ -134,11 +134,6 @@ fun ChatScreen(
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.
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
val context = LocalContext.current
@ -234,23 +229,16 @@ fun ChatScreen(
chatModel = null
}
fun unlockAndOpen(summary: ConversationSummary, password: String? = null) {
fun unlockAndOpen(summary: ConversationSummary) {
val cfg = session.config ?: return
scope.launch {
loadError = null
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token, password)
val unlockToken = KaizenApi.requestUnlock(cfg.baseUrl, cfg.token)
if (unlockToken == null) {
if (password != null) {
passwordUnlockError = context.getString(R.string.unlock_password_wrong)
} 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
}
passwordUnlockTarget = null
passwordUnlockError = null
passwordUnlockForToggle = false
val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken)
if (stored.isNotEmpty()) {
messages.clear()
@ -273,26 +261,21 @@ 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
fun openConversation(summary: ConversationSummary) {
val cfg = session.config ?: return
if (summary.locked) {
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)
if (activity == null) {
unlockAndOpen(summary)
return
}
if (!dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
unlockAndOpen(summary)
return
}
dev.kaizen.app.auth.BiometricUnlock.authenticate(
@ -302,17 +285,11 @@ fun ChatScreen(
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.Success -> unlockAndOpen(summary)
is dev.kaizen.app.auth.BiometricUnlock.Result.NotAvailable -> unlockAndOpen(summary)
is dev.kaizen.app.auth.BiometricUnlock.Result.Failed -> { /* user cancelled */ }
}
}
}
fun openConversation(summary: ConversationSummary) {
val cfg = session.config ?: return
if (summary.locked) {
requestBiometricOrPassword(summary) { unlockAndOpen(summary) }
return
}
viewingLockedChat = false
@ -503,7 +480,6 @@ fun ChatScreen(
AppScreen.Settings -> {
SettingsScreen(
viewModel = settingsViewModel,
config = session.config,
onBack = { currentScreen = AppScreen.Chat },
modifier = modifier
)
@ -558,22 +534,8 @@ fun ChatScreen(
refreshConversations()
}
is SidebarAction.ToggleLock -> {
if (action.locked) {
// Locking — no auth needed
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked))
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 },
)
}
// 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,
)
}
}
}
}

View file

@ -63,21 +63,10 @@ data class ConversationSummary(
)
@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 CreateConvoRequest(val title: String? = null)
@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). */
@Serializable
data class Attachment(
@ -382,12 +371,11 @@ object KaizenApi {
}
/** 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) {
val body = json.encodeToString(UnlockRequest(password))
val req = Request.Builder()
.url("$baseUrl/api/v1/auth/unlock")
.post(body.toRequestBody(jsonMedia))
.post("{}".toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token")
.build()
try {
@ -575,63 +563,4 @@ object KaizenApi {
// 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")

View file

@ -91,9 +91,6 @@ class SecureStore(context: Context) {
fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null)
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() {
prefs.edit().clear().apply()
}
@ -110,6 +107,5 @@ class SecureStore(context: Context) {
const val KEY_APPEARANCE = "appearance"
const val KEY_USER_NAME = "user_name"
const val KEY_LOCALE = "locale"
const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
}
}

View file

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

View file

@ -57,7 +57,6 @@ import dev.kaizen.app.chat.MeshBackground
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
config: dev.kaizen.app.net.ServerConfig? = null,
onBack: () -> Unit,
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))
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)
}
SecuritySection(viewModel = viewModel, config = config)
}
}
}

View file

@ -1,17 +1,11 @@
package dev.kaizen.app.settings
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.ServerConfig
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class AppAppearance { Light, Dark, System }
@ -75,51 +69,4 @@ class SettingsViewModel(private val store: SecureStore? = null) : ViewModel() {
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 }
}

View file

@ -5,17 +5,16 @@
android:height="108dp"
android:viewportWidth="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">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="50"
android:gradientRadius="72">
<item android:color="#FF141C28" android:offset="0.0" />
<item android:color="#FF0E1420" android:offset="0.5" />
<item android:color="#FF080C14" android:offset="1.0" />
android:centerY="54"
android:gradientRadius="76">
<item android:color="#FF111824" android:offset="0.0" />
<item android:color="#FF0A0E16" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>

View file

@ -5,17 +5,16 @@
android:height="108dp"
android:viewportWidth="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">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="50"
android:gradientRadius="72">
<item android:color="#FFF9F3EB" android:offset="0.0" />
<item android:color="#FFF4ECE0" android:offset="0.5" />
<item android:color="#FFEAE0D0" android:offset="1.0" />
android:centerY="54"
android:gradientRadius="76">
<item android:color="#FFF8F4EE" android:offset="0.0" />
<item android:color="#FFF1EBE3" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>

View file

@ -6,183 +6,86 @@
android:viewportWidth="108"
android:viewportHeight="108">
<!-- L0: Soft outer halo — large, diffuse amber glow -->
<path android:pathData="M54,54m-34,0a34,34 0,1 1,68 0a34,34 0,1 1,-68 0">
<!-- Soft ambient glow behind the orb -->
<path android:pathData="M54,54m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="52"
android:centerY="50"
android:gradientRadius="34">
<item android:color="#40D4942A" android:offset="0.0" />
<item android:color="#20D4942A" android:offset="0.4" />
<item android:color="#08D4942A" android:offset="0.7" />
android:centerX="54"
android:centerY="54"
android:gradientRadius="30">
<item android:color="#50E0A23E" android:offset="0.0" />
<item android:color="#18E0A23E" android:offset="0.6" />
<item android:color="#00000000" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- 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">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="48"
android:centerY="46"
android:gradientRadius="26">
<item android:color="#FFFCE8C0" android:offset="0.0" />
<item android:color="#FFEAB86A" android:offset="0.28" />
<item android:color="#FFD49430" android:offset="0.52" />
<item android:color="#FFBE7A20" android:offset="0.72" />
<item android:color="#FF9A5A12" android:offset="0.88" />
<item android:color="#FF7A4410" android:offset="1.0" />
android:centerX="47"
android:centerY="45"
android:gradientRadius="28">
<item android:color="#FFFFFBE6" android:offset="0.0" />
<item android:color="#FFFFD685" android:offset="0.25" />
<item android:color="#FFE0A23E" android:offset="0.50" />
<item android:color="#FFC8862A" android:offset="0.75" />
<item android:color="#FF7A4400" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- 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">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="50"
android:centerY="40"
android:gradientRadius="24">
<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">
android:centerY="48"
android:gradientRadius="22">
<item android:color="#00000000" android:offset="0.0" />
<item android:color="#00000000" android:offset="0.55" />
<item android:color="#30301800" android:offset="0.78" />
<item android:color="#70301800" android:offset="0.92" />
<item android:color="#90201000" android:offset="1.0" />
<item android:color="#00000000" android:offset="0.50" />
<item android:color="#993D1E00" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- L5: Internal caustic structure — off-center radial for depth -->
<path android:pathData="M54,54m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 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">
<!-- Primary specular highlight — top-left, bright white gloss -->
<path android:pathData="M46,44m-9,0a9,9 0,1 1,18 0a9,9 0,1 1,-18 0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="46"
android:centerY="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:gradientRadius="26">
<item android:color="#20FFFFFF" android:offset="0.0" />
<item android:color="#08FFFFFF" android:offset="0.6" />
<item android:color="#04FFFFFF" android:offset="1.0" />
android:gradientRadius="9">
<item android:color="#E0FFFFFF" android:offset="0.0" />
<item android:color="#40FFFFFF" android:offset="0.4" />
<item android:color="#00FFFFFF" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- Secondary warm reflection — bottom-right bounce light -->
<path android:pathData="M63,63m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="63"
android:centerY="63"
android:gradientRadius="7">
<item android:color="#80FFD080" android:offset="0.0" />
<item android:color="#00FFD080" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- Crisp outer rim — thin glass edge -->
<path
android: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>

View file

@ -6,40 +6,31 @@
android:viewportWidth="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">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="48"
android:centerY="44"
android:gradientRadius="26">
android:centerY="46"
android:gradientRadius="24">
<item android:color="#FFFFFFFF" android:offset="0.0" />
<item android:color="#CCFFFFFF" android:offset="0.5" />
<item android:color="#60FFFFFF" android:offset="1.0" />
<item android:color="#80FFFFFF" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- Specular dot — makes it read as a sphere, not a flat circle -->
<path android:pathData="M45,43m-5,0a5,4 0,1 1,10 0a5,4 0,1 1,-10 0">
<!-- Specular dot -->
<path android:pathData="M54,54m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="45"
android:centerY="43"
android:gradientRadius="5">
android:gradientRadius="8">
<item android:color="#FFFFFFFF" android:offset="0.0" />
<item android:color="#80FFFFFF" android:offset="0.4" />
<item android:color="#00FFFFFF" android:offset="1.0" />
</gradient>
</aapt:attr>
</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>

View file

@ -96,12 +96,6 @@
<string name="biometric_not_available">Biometrics not available</string>
<string name="biometric_failed">Authentication failed</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 -->
<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_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 -->
<string name="settings_title">Settings</string>
<string name="settings_back">Back</string>

View file

@ -98,12 +98,6 @@
<string name="biometric_not_available">Biometrie nicht verfügbar</string>
<string name="biometric_failed">Authentifizierung fehlgeschlagen</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 -->
<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_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 -->
<string name="settings_title">Einstellungen</string>
<string name="settings_back">Zurück</string>