app 0.3.0: Login/Registrierung, Abmelden, 401 → Login; Previews + Landingpage v0.3.0

This commit is contained in:
OpenClaw Dev 2026-10-31 11:56:53 +01:00
parent 0ef39cffd6
commit 97646da970
48 changed files with 293 additions and 47 deletions

View file

@ -31,8 +31,8 @@ android {
applicationId = "dev.byteclaw.client"
minSdk = 26
targetSdk = 35
versionCode = 2
versionName = "0.2.0"
versionCode = 3
versionName = "0.3.0"
}
signingConfigs {

View file

@ -26,6 +26,7 @@ import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.ChatViewModel
import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.LoginScreen
class MainActivity : ComponentActivity() {
private var voiceCallback: ((String) -> Unit)? = null
@ -61,7 +62,16 @@ class MainActivity : ComponentActivity() {
val vm: ChatViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val nav = rememberNavController()
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
val loggedIn = state.settings.isConfigured
androidx.compose.runtime.LaunchedEffect(loggedIn) {
val target = if (loggedIn) "chat" else "login"
if (nav.currentDestination?.route != target) nav.navigate(target) { popUpTo(0) { inclusive = true } }
}
NavHost(nav, startDestination = if (loggedIn) "chat" else "login") {
composable("login") {
LoginScreen(state, onAuthenticate = { u, n, p, r -> vm.authenticate(u, n, p, r) },
onAdminToken = { u, t -> vm.useAdminToken(u, t) })
}
composable("chat") {
androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() }
ChatScreen(
@ -83,7 +93,7 @@ class MainActivity : ComponentActivity() {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
onSelectProvider = { p, m -> vm.selectProvider(p, m) })
onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout)
}
}
}

View file

@ -17,7 +17,10 @@ import okhttp3.WebSocketListener
import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiException(msg: String) : IOException(msg)
open class ApiException(msg: String, val code: Int = 0) : IOException(msg)
/** Token missing, expired or revoked: the app returns to the login screen. */
class UnauthorizedException : ApiException("Sitzung abgelaufen – bitte erneut anmelden", 401)
/** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */
class ByteClawApi(private val settings: ServerSettings) {
@ -33,7 +36,8 @@ class ByteClawApi(private val settings: ServerSettings) {
private suspend fun call(req: Request): String = withContext(Dispatchers.IO) {
http.newCall(req).execute().use { r: Response ->
val body = r.body?.string().orEmpty()
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}")
if (r.code == 401) throw UnauthorizedException()
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}", r.code)
body
}
}
@ -42,6 +46,13 @@ class ByteClawApi(private val settings: ServerSettings) {
http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful }
}
suspend fun me(): AuthUser = json.decodeFromString(call(Request.Builder().url(url("/api/auth/me")).build()))
/** Revokes the current token on the server. */
suspend fun logout() {
call(Request.Builder().url(url("/api/auth/logout")).post(ByteArray(0).toRequestBody(null)).build())
}
suspend fun info(): ServerInfo = json.decodeFromString(call(Request.Builder().url(url("/api/info")).build()))
suspend fun sessions(): List<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build()))
@ -118,6 +129,21 @@ class ByteClawApi(private val settings: ServerSettings) {
}
companion object {
private val authJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
/** POST /api/auth/login or /register without a bearer token. Errors carry German text. */
suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean): AuthResponse =
withContext(Dispatchers.IO) {
val path = if (register) "/api/auth/register" else "/api/auth/login"
val body = authJson.encodeToString(Credentials.serializer(), Credentials(username.trim(), password)).toRequestBody(JSON)
val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + path).toHttpUrl()).post(body).build()
shared.newCall(req).execute().use { r ->
val text = r.body?.string().orEmpty()
if (!r.isSuccessful) throw ApiException(AuthErrors.german(r.code, text), r.code)
authJson.decodeFromString(AuthResponse.serializer(), text)
}
}
private val JSON = "application/json".toMediaType()
private val shared = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)

View file

@ -92,3 +92,33 @@ object ProviderPresets {
@Serializable
data class RenameRequest(val title: String)
@Serializable
data class Credentials(val username: String, val password: String)
@Serializable
data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false)
@Serializable
data class AuthResponse(val token: String, val user: AuthUser = AuthUser())
/** Maps server auth errors to German UI text. */
object AuthErrors {
fun german(code: Int, body: String): String = when {
code == 401 -> "Benutzername oder Passwort falsch"
code == 409 -> "Benutzername ist bereits vergeben"
code == 403 -> "Registrierung ist auf diesem Server deaktiviert"
code == 429 -> "Zu viele Versuche – bitte später erneut probieren"
code == 400 && body.contains("username") -> "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)"
code == 400 && body.contains("password") -> "Passwort: mindestens 8 Zeichen"
else -> "Fehler (HTTP $code)"
}
/** Client-side check before contacting the server; null = ok. */
fun validate(username: String, password: String): String? {
val u = username.trim()
if (u.length !in 3..32 || !u.all { it.isLetterOrDigit() && it.code < 128 || it in "._-" }) return "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)"
if (password.length < 8) return "Passwort: mindestens 8 Zeichen"
return null
}
}

View file

@ -5,7 +5,7 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
data class ServerSettings(val serverUrl: String = "", val token: String = "") {
data class ServerSettings(val serverUrl: String = "", val token: String = "", val username: String = "") {
val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank()
}
@ -22,14 +22,17 @@ class SettingsStore(context: Context) {
fun load() = ServerSettings(
serverUrl = prefs.getString(KEY_URL, "") ?: "",
token = prefs.getString(KEY_TOKEN, "") ?: "",
username = prefs.getString(KEY_USER, "") ?: "",
)
fun save(s: ServerSettings) {
prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim()).apply()
prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim())
.putString(KEY_USER, s.username.trim()).apply()
}
private companion object {
const val KEY_URL = "server_url"
const val KEY_TOKEN = "token"
const val KEY_USER = "username"
}
}

View file

@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dev.byteclaw.client.data.ActiveProvider
import dev.byteclaw.client.data.ByteClawApi
import dev.byteclaw.client.data.AuthErrors
import dev.byteclaw.client.data.UnauthorizedException
import dev.byteclaw.client.data.ProviderInfo
import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ServerSettings
@ -39,6 +41,9 @@ data class UiState(
val sessionProvider: ActiveProvider? = null,
/** null = unknown, true/false = last health check result. */
val online: Boolean? = null,
/** Login screen state. */
val authBusy: Boolean = false,
val authError: String? = null,
) {
/** What the current chat effectively uses: session override, else the global active provider. */
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
@ -73,6 +78,54 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
_state.update { it.copy(settings = store.load(), error = null) }
}
/** Login or registration; on success the token is stored encrypted and the chat opens. */
fun authenticate(serverUrl: String, username: String, password: String, register: Boolean) {
val urlErr = ByteClawApi.validateUrl(serverUrl)
val err = urlErr ?: AuthErrors.validate(username, password)
if (err != null) { _state.update { it.copy(authError = err) }; return }
_state.update { it.copy(authBusy = true, authError = null) }
viewModelScope.launch {
runCatching { ByteClawApi.authenticate(serverUrl, username, password, register) }
.onSuccess { r ->
store.save(ServerSettings(serverUrl, r.token, r.user.username))
_state.update { UiState(settings = store.load()) }
}
.onFailure { e -> _state.update { it.copy(authBusy = false, authError = e.message ?: "Verbindung fehlgeschlagen") } }
}
}
/** Back-compat: sign in with the server's static admin token. */
fun useAdminToken(serverUrl: String, token: String) {
val err = ByteClawApi.validateUrl(serverUrl) ?: if (token.trim().length < 16) "Token: mindestens 16 Zeichen" else null
if (err != null) { _state.update { it.copy(authError = err) }; return }
_state.update { it.copy(authBusy = true, authError = null) }
viewModelScope.launch {
val s = ServerSettings(serverUrl, token, "admin")
runCatching { ByteClawApi(s).me() }
.onSuccess { u -> store.save(s.copy(username = u.username)); _state.update { UiState(settings = store.load()) } }
.onFailure { e -> _state.update { it.copy(authBusy = false, authError = if (e is UnauthorizedException) "Token ungültig" else e.message) } }
}
}
fun logout() {
val s = _state.value.settings
job?.cancel()
viewModelScope.launch { runCatching { ByteClawApi(s).logout() } }
clearSession(null)
}
/** Drops the token (keeps URL + username for the login form). */
private fun clearSession(reason: String?) {
val s = _state.value.settings
store.save(s.copy(token = ""))
_state.update { UiState(settings = store.load(), authError = reason) }
}
/** Central error handler: 401 sends the user back to the login screen. */
private fun fail(e: Throwable) {
if (e is UnauthorizedException) clearSession(e.message) else _state.update { it.copy(error = e.message) }
}
fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch {
val msg = runCatching {
val i = ByteClawApi(s).info()
@ -91,7 +144,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
fun refreshSessions() = viewModelScope.launch {
runCatching { api().sessions() }
.onSuccess { list -> _state.update { it.copy(sessions = list) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
.onFailure { e -> fail(e) }
}
fun openSession(id: String) = viewModelScope.launch {
@ -113,7 +166,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
_state.update { it.copy(sessionProvider = ss.provider.takeIf { p -> p.isNotBlank() }?.let { p -> ActiveProvider(p, ss.model) }) }
}
}
.onFailure { e -> _state.update { it.copy(error = e.message) } }
.onFailure { e -> fail(e) }
}
fun deleteSession(id: String) = viewModelScope.launch {
@ -122,7 +175,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
if (_state.value.sessionId == id) newChat()
refreshSessions()
}
.onFailure { e -> _state.update { it.copy(error = e.message) } }
.onFailure { e -> fail(e) }
}
fun send(text: String) {
@ -148,7 +201,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
System.currentTimeMillis() - started)
}
"cancelled" -> markCancelled()
"error" -> _state.update { s ->
"error" -> if (ev.text == "HTTP 401") clearSession("Sitzung abgelaufen – bitte erneut anmelden") else _state.update { s ->
s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
}
}
@ -171,7 +224,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
if (t.isEmpty()) return@launch
runCatching { api().renameSession(id, t) }
.onSuccess { _state.update { s -> s.copy(sessions = s.sessions.map { if (it.id == id) it.copy(title = t) else it }) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
.onFailure { e -> fail(e) }
}
/** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */
@ -208,14 +261,14 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
runCatching { api().setSessionProvider(sid, name, model) }
.onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
.onFailure { e -> fail(e) }
}
}
fun loadProviders() = viewModelScope.launch {
runCatching { api().providers() }
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
.onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }
.onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }
}
fun loadModels(name: String) = viewModelScope.launch {

View file

@ -0,0 +1,87 @@
package dev.byteclaw.client.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import dev.byteclaw.client.data.ByteClawApi
/** Shown whenever no valid token is stored: sign in, sign up, or (advanced) use the static admin token. */
@Composable
fun LoginScreen(
state: UiState,
onAuthenticate: (url: String, user: String, pass: String, register: Boolean) -> Unit,
onAdminToken: (url: String, token: String) -> Unit = { _, _ -> },
initialRegister: Boolean = false,
initialUser: String? = null,
) {
var register by remember { mutableStateOf(initialRegister) }
var url by remember { mutableStateOf(state.settings.serverUrl) }
var user by remember { mutableStateOf(initialUser ?: state.settings.username) }
var pass by remember { mutableStateOf("") }
var pass2 by remember { mutableStateOf("") }
var adminMode by remember { mutableStateOf(false) }
var token by remember { mutableStateOf("") }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
val mismatch = register && pass2.isNotEmpty() && pass != pass2
Scaffold { pad ->
Column(
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(24.dp))
Text("ByteClaw", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
Text(if (register) "Neues Konto erstellen" else "Bei deinem Server anmelden", style = MaterialTheme.typography.bodyLarge)
if (!adminMode) {
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(!register, { register = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Anmelden") }
SegmentedButton(register, { register = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Registrieren") }
}
}
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
placeholder = { Text("https://claw.example.org") }, singleLine = true,
isError = urlError != null, supportingText = urlError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
if (adminMode) {
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Admin-Token") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
} else {
OutlinedTextField(user, { user = it }, Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true,
supportingText = if (register) { { Text("3–32 Zeichen: Buchstaben, Ziffern, . _ -") } } else null)
OutlinedTextField(pass, { pass = it }, Modifier.fillMaxWidth(), label = { Text("Passwort") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
supportingText = if (register) { { Text("Mindestens 8 Zeichen") } } else null)
if (register) {
OutlinedTextField(pass2, { pass2 = it }, Modifier.fillMaxWidth(), label = { Text("Passwort wiederholen") }, singleLine = true,
isError = mismatch, supportingText = if (mismatch) { { Text("Passwörter stimmen nicht überein") } } else null,
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
}
}
state.authError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
modifier = Modifier.fillMaxWidth(),
enabled = !state.authBusy && url.isNotBlank() && urlError == null &&
(if (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2)),
onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register) },
) {
if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" })
}
TextButton(onClick = { adminMode = !adminMode }) {
Text(if (adminMode) "Zurück zur Anmeldung" else "Stattdessen Admin-Token verwenden")
}
Text("Das Login-Token wird verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.",
style = MaterialTheme.typography.bodySmall)
}
}
}

View file

@ -270,11 +270,9 @@ fun SettingsScreen(
onLoadModels: (String) -> Unit = {},
onSelectProvider: (String, String) -> Unit = { _, _ -> },
initialStatus: String? = null,
onLogout: () -> Unit = {},
) {
var url by remember { mutableStateOf(state.settings.serverUrl) }
var token by remember { mutableStateOf(state.settings.token) }
var status by remember { mutableStateOf(initialStatus) }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") },
@ -282,28 +280,18 @@ fun SettingsScreen(
}) { pad ->
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item {
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
placeholder = { Text("https://claw.example.org") }, singleLine = true,
isError = urlError != null, supportingText = { urlError?.let { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
}
item {
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
}
item {
Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.",
style = MaterialTheme.typography.bodySmall)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
onSave(ServerSettings(url, token)); onBack()
}) { Text("Speichern") }
OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = {
status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
}) { Text("Verbindung testen") }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Konto", style = MaterialTheme.typography.titleMedium)
Text("Angemeldet als ${state.settings.username.ifBlank { "unbekannt" }}")
Text("Server: ${state.settings.serverUrl}", style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
status = "Teste…"; onTest(state.settings) { status = it }
}) { Text("Verbindung testen") }
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Abmelden") }
}
}
}
}
status?.let { item { Text(it) } }

View file

@ -0,0 +1,36 @@
package dev.byteclaw.client
import dev.byteclaw.client.data.AuthErrors
import dev.byteclaw.client.data.AuthResponse
import dev.byteclaw.client.data.ServerSettings
import kotlinx.serialization.json.Json
import org.junit.Assert.*
import org.junit.Test
class AuthTest {
@Test fun validation() {
assertNotNull(AuthErrors.validate("ab", "longenough"))
assertNotNull(AuthErrors.validate("a".repeat(33), "longenough"))
assertNotNull(AuthErrors.validate("bad name", "longenough"))
assertNotNull(AuthErrors.validate("bob", "short"))
assertNull(AuthErrors.validate("crypto_1.x", "longenough"))
}
@Test fun germanErrors() {
assertEquals("Benutzername oder Passwort falsch", AuthErrors.german(401, ""))
assertEquals("Benutzername ist bereits vergeben", AuthErrors.german(409, ""))
assertTrue(AuthErrors.german(429, "").startsWith("Zu viele"))
assertTrue(AuthErrors.german(400, "{\"error\":\"password must be\"}").startsWith("Passwort"))
}
@Test fun parsesAuthResponse() {
val r = Json { ignoreUnknownKeys = true }.decodeFromString(AuthResponse.serializer(),
"""{"token":"bc_x","expires_at":"2026-12-01T00:00:00Z","user":{"id":2,"username":"alice"}}""")
assertEquals("bc_x", r.token); assertEquals("alice", r.user.username)
}
@Test fun configuredNeedsToken() {
assertFalse(ServerSettings("https://x", "", "alice").isConfigured)
assertTrue(ServerSettings("https://x", "bc_x", "alice").isConfigured)
}
}

View file

@ -10,6 +10,7 @@ import dev.byteclaw.client.ui.ChatLine
import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.LoginScreen
import dev.byteclaw.client.ui.UiState
import dev.byteclaw.client.ui.ProviderUi
import dev.byteclaw.client.data.ProviderInfo
@ -35,7 +36,7 @@ class PreviewScreenshots(private val dark: Boolean) {
maxPercentDifference = 0.1,
)
private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real")
private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real", "crypto")
private fun snap(name: String, content: @Composable () -> Unit) =
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) }
@ -149,4 +150,15 @@ class PreviewScreenshots(private val dark: Boolean) {
onSave = {}, onTest = { _, _ -> }, onBack = {},
)
}
@Test fun login() = snap("login") {
LoginScreen(state = UiState(settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> })
}
@Test fun loginRegister() = snap("login_register") {
LoginScreen(
state = UiState(settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After