fix locale, add stop button, auto-logout on 401, migrate login shadows
- EmptyHero date respects system locale instead of hardcoded German - Stop button (red square) replaces send/call during streaming, cancels stream cleanly - Auto-logout on HTTP 401 (expired token) routes to login instead of error banner - LoginScreen migrated from Modifier.shadow to kaizenShadow (design system pillar 4)
This commit is contained in:
parent
3032dc869f
commit
141d4de227
5 changed files with 48 additions and 8 deletions
|
|
@ -33,7 +33,6 @@ 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
|
||||
|
|
@ -49,6 +48,8 @@ import dev.kaizen.app.chat.MeshBackground
|
|||
import dev.kaizen.app.net.DEFAULT_BASE_URL
|
||||
import dev.kaizen.app.net.LoginResult
|
||||
import dev.kaizen.app.net.SessionViewModel
|
||||
import dev.kaizen.app.ui.effect.KaizenShadows
|
||||
import dev.kaizen.app.ui.effect.kaizenShadow
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import dev.kaizen.app.R
|
||||
|
|
@ -181,7 +182,7 @@ private fun GlassField(
|
|||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 420.dp)
|
||||
.shadow(elevation = 6.dp, shape = KaizenShapes.lg, clip = false)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
|
||||
.clip(KaizenShapes.lg)
|
||||
.background(glassBg)
|
||||
.border(1.2.dp, glassBorder, KaizenShapes.lg)
|
||||
|
|
@ -220,7 +221,7 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit
|
|||
.fillMaxWidth()
|
||||
.widthIn(max = 420.dp)
|
||||
.heightIn(min = 54.dp)
|
||||
.shadow(elevation = 8.dp, shape = KaizenShapes.lg, clip = false)
|
||||
.kaizenShadow(stack = KaizenShadows.level2, cornerRadius = 20.dp)
|
||||
.clip(KaizenShapes.lg)
|
||||
.then(fill)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
|
|||
val cs = MaterialTheme.colorScheme
|
||||
val now = remember { LocalDateTime.now() }
|
||||
val dateLine = remember {
|
||||
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
|
||||
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.getDefault()))
|
||||
}
|
||||
|
||||
Column(
|
||||
|
|
@ -525,6 +525,8 @@ fun ChatInput(
|
|||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
enabled: Boolean,
|
||||
isStreaming: Boolean = false,
|
||||
onStop: () -> Unit = {},
|
||||
pendingFiles: List<PendingFile> = emptyList(),
|
||||
onAddClick: () -> Unit = {},
|
||||
onRemoveFile: (String) -> Unit = {},
|
||||
|
|
@ -590,7 +592,9 @@ fun ChatInput(
|
|||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
if (hasContent) {
|
||||
if (isStreaming) {
|
||||
StopButton(onClick = onStop)
|
||||
} else if (hasContent) {
|
||||
SendButton(enabled = canSend, onClick = onSend)
|
||||
} else {
|
||||
CallButton(onClick = { /* TODO: call feature */ })
|
||||
|
|
@ -702,6 +706,26 @@ private fun CallButton(onClick: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StopButton(onClick: () -> Unit) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val pressScale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "stop")
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.size(38.dp)
|
||||
.scale(pressScale)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(Color(0xFFEF4444).copy(alpha = if (isDark) 0.85f else 0.80f))
|
||||
.clickable(interactionSource = interaction, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attachment rendering ────────────────────────────────────────────────────
|
||||
|
||||
private object NetworkImageCache {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
|
@ -108,6 +109,7 @@ fun ChatScreen(
|
|||
var samplingPrefs by remember { mutableStateOf(SamplingPrefs()) }
|
||||
var usedTokens by remember { mutableStateOf(0) }
|
||||
var chatModel by remember { mutableStateOf<String?>(null) }
|
||||
var streamJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
// Navigation State
|
||||
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
|
||||
|
|
@ -199,7 +201,10 @@ fun ChatScreen(
|
|||
models = r.data
|
||||
if (r.data.isNotEmpty()) session.store.saveModelsCache(r.data)
|
||||
}
|
||||
is FetchResult.Fail -> errors.add(r.reason)
|
||||
is FetchResult.Fail -> {
|
||||
if ("HTTP 401" in r.reason) { session.logout(); return@LaunchedEffect }
|
||||
errors.add(r.reason)
|
||||
}
|
||||
}
|
||||
KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me ->
|
||||
me.name?.let { settingsViewModel.updateUserName(it) }
|
||||
|
|
@ -395,7 +400,7 @@ fun ChatScreen(
|
|||
.filter { it.id != assistantId }
|
||||
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
|
||||
|
||||
scope.launch {
|
||||
streamJob = scope.launch {
|
||||
haptics.thinkingStart()
|
||||
var sawContent = false
|
||||
try {
|
||||
|
|
@ -490,7 +495,12 @@ fun ChatScreen(
|
|||
}
|
||||
} catch (e: Exception) {
|
||||
val idx = messages.indexOfLast { it.id == assistantId }
|
||||
if (idx >= 0) {
|
||||
if (e is kotlinx.coroutines.CancellationException) {
|
||||
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
||||
} else if (e is ChatHttpException && e.code == 401) {
|
||||
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
|
||||
session.logout()
|
||||
} else if (idx >= 0) {
|
||||
val existing = messages[idx].content
|
||||
val errorSuffix = "\n\n⚠ ${chatErrorText(e, context)}"
|
||||
messages[idx] = messages[idx].copy(
|
||||
|
|
@ -501,6 +511,7 @@ fun ChatScreen(
|
|||
}
|
||||
} finally {
|
||||
isStreaming = false
|
||||
streamJob = null
|
||||
haptics.responseEnd()
|
||||
}
|
||||
}
|
||||
|
|
@ -845,6 +856,8 @@ fun ChatScreen(
|
|||
onValueChange = { input = it },
|
||||
onSend = { send(input) },
|
||||
enabled = !isStreaming,
|
||||
isStreaming = isStreaming,
|
||||
onStop = { streamJob?.cancel() },
|
||||
pendingFiles = pendingFiles,
|
||||
onAddClick = { showAttachMenu = !showAttachMenu },
|
||||
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
<string name="chat_remove">Remove</string>
|
||||
<string name="chat_error">Error</string>
|
||||
<string name="chat_open_menu">Open menu</string>
|
||||
<string name="chat_stop">Stop response</string>
|
||||
<string name="attach_camera">Take Photo</string>
|
||||
<string name="attach_gallery">Photo & Video</string>
|
||||
<string name="attach_file">Choose File</string>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
<string name="chat_remove">Entfernen</string>
|
||||
<string name="chat_error">Fehler</string>
|
||||
<string name="chat_open_menu">Menü öffnen</string>
|
||||
<string name="chat_stop">Antwort stoppen</string>
|
||||
<string name="attach_camera">Foto aufnehmen</string>
|
||||
<string name="attach_gallery">Foto & Video</string>
|
||||
<string name="attach_file">Datei auswählen</string>
|
||||
|
|
|
|||
Loading…
Reference in a new issue