test(android): Paparazzi preview screenshots with demo data; stateless screen composables

This commit is contained in:
OpenClaw Dev 2026-10-25 04:34:17 +01:00
parent 7b92e259ad
commit 1e32997504
20 changed files with 141 additions and 17 deletions

View file

@ -15,6 +15,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import dev.byteclaw.client.ui.ByteClawTheme
import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.ChatViewModel
import dev.byteclaw.client.ui.SessionsScreen
@ -25,20 +26,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
ByteClawTheme(dark = isSystemInDarkTheme()) {
Surface {
val vm: ChatViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val nav = rememberNavController()
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
composable("chat") {
ChatScreen(vm, state, onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") })
ChatScreen(
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") },
)
}
composable("sessions") {
SessionsScreen(vm, state, onBack = { nav.popBackStack() }, onOpen = { nav.popBackStack() })
SessionsScreen(
state, onRefresh = { vm.refreshSessions() },
onOpen = { id -> vm.openSession(id); nav.popBackStack() },
onDelete = { id -> vm.deleteSession(id) }, onBack = { nav.popBackStack() },
)
}
composable("settings") {
SettingsScreen(vm, state, onBack = {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
})
}

View file

@ -30,8 +30,16 @@ import dev.byteclaw.client.data.ServerSettings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSettings: () -> Unit) {
var input by remember { mutableStateOf("") }
fun ChatScreen(
state: UiState,
onSend: (String) -> Unit,
onNewChat: () -> Unit,
onDismissError: () -> Unit,
onSessions: () -> Unit,
onSettings: () -> Unit,
initialInput: String = "",
) {
var input by remember { mutableStateOf(initialInput) }
val listState = rememberLazyListState()
LaunchedEffect(state.lines.size, state.lines.lastOrNull()?.text?.length) {
if (state.lines.isNotEmpty()) listState.animateScrollToItem(state.lines.lastIndex)
@ -42,7 +50,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
title = { Text("ByteClaw") },
navigationIcon = { IconButton(onClick = onSessions) { Icon(Icons.AutoMirrored.Filled.List, "Sessions") } },
actions = {
IconButton(onClick = vm::newChat) { Icon(Icons.Filled.Add, "Neuer Chat") }
IconButton(onClick = onNewChat) { Icon(Icons.Filled.Add, "Neuer Chat") }
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Einstellungen") }
},
)
@ -56,7 +64,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
}
state.error?.let {
Card(
Modifier.padding(horizontal = 12.dp, vertical = 4.dp).fillMaxWidth().clickable(onClick = vm::dismissError),
Modifier.padding(horizontal = 12.dp, vertical = 4.dp).fillMaxWidth().clickable(onClick = onDismissError),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) { Text(it, Modifier.padding(12.dp)) }
}
@ -71,7 +79,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
enabled = state.settings.isConfigured,
)
IconButton(
onClick = { vm.send(input); input = "" },
onClick = { onSend(input); input = "" },
enabled = !state.busy && input.isNotBlank() && state.settings.isConfigured,
) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") }
}
@ -105,8 +113,14 @@ private fun Bubble(line: ChatLine) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen: () -> Unit) {
LaunchedEffect(Unit) { vm.refreshSessions() }
fun SessionsScreen(
state: UiState,
onRefresh: () -> Unit,
onOpen: (String) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit,
) {
LaunchedEffect(Unit) { onRefresh() }
Scaffold(topBar = {
TopAppBar(title = { Text("Sessions") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
@ -119,8 +133,8 @@ fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen
ListItem(
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1) },
supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
trailingContent = { IconButton(onClick = { vm.deleteSession(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
modifier = Modifier.clickable { vm.openSession(s.id); onOpen() },
trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
modifier = Modifier.clickable { onOpen(s.id) },
)
HorizontalDivider()
}
@ -130,10 +144,16 @@ fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit) {
fun SettingsScreen(
state: UiState,
onSave: (ServerSettings) -> Unit,
onTest: (ServerSettings, (String) -> Unit) -> Unit,
onBack: () -> Unit,
initialStatus: String? = null,
) {
var url by remember { mutableStateOf(state.settings.serverUrl) }
var token by remember { mutableStateOf(state.settings.token) }
var status by remember { mutableStateOf<String?>(null) }
var status by remember { mutableStateOf(initialStatus) }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") },
@ -151,10 +171,10 @@ fun SettingsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit) {
style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
vm.saveSettings(ServerSettings(url, token)); onBack()
onSave(ServerSettings(url, token)); onBack()
}) { Text("Speichern") }
OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = {
status = "Teste…"; vm.testConnection(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
}) { Text("Verbindung testen") }
}
status?.let { Text(it) }

View file

@ -0,0 +1,11 @@
package dev.byteclaw.client.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
@Composable
fun ByteClawTheme(dark: Boolean, content: @Composable () -> Unit) {
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme(), content = content)
}