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

@ -135,3 +135,7 @@ In der App unter Einstellungen Server-URL (`https://…`; im Debug-Build auch `h
- Session-Kontext zusammenfassen statt nur Fenster der letzten N Nachrichten - Session-Kontext zusammenfassen statt nur Fenster der letzten N Nachrichten
- Weitere Provider (Anthropic nativ), Abbrechen laufender Antworten über WebSocket - Weitere Provider (Anthropic nativ), Abbrechen laufender Antworten über WebSocket
- Optional: Einrichtung per QR-Code (URL + Token) - Optional: Einrichtung per QR-Code (URL + Token)
## Vorschau
Screenshots mit Demo-Daten (Paparazzi, ohne Emulator) liegen in `preview/` (hell/dunkel). Neu erzeugen: `cd android && ./gradlew recordPaparazziDebug`.

View file

@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.paparazzi) // screenshot tests only (JVM, test source set)
} }
android { android {

View file

@ -15,6 +15,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dev.byteclaw.client.ui.ByteClawTheme
import dev.byteclaw.client.ui.ChatScreen import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.ChatViewModel import dev.byteclaw.client.ui.ChatViewModel
import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SessionsScreen
@ -25,20 +26,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { ByteClawTheme(dark = isSystemInDarkTheme()) {
Surface { Surface {
val vm: ChatViewModel = viewModel() val vm: ChatViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val nav = rememberNavController() val nav = rememberNavController()
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") { NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
composable("chat") { 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") { 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") { 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 } } 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSettings: () -> Unit) { fun ChatScreen(
var input by remember { mutableStateOf("") } state: UiState,
onSend: (String) -> Unit,
onNewChat: () -> Unit,
onDismissError: () -> Unit,
onSessions: () -> Unit,
onSettings: () -> Unit,
initialInput: String = "",
) {
var input by remember { mutableStateOf(initialInput) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
LaunchedEffect(state.lines.size, state.lines.lastOrNull()?.text?.length) { LaunchedEffect(state.lines.size, state.lines.lastOrNull()?.text?.length) {
if (state.lines.isNotEmpty()) listState.animateScrollToItem(state.lines.lastIndex) 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") }, title = { Text("ByteClaw") },
navigationIcon = { IconButton(onClick = onSessions) { Icon(Icons.AutoMirrored.Filled.List, "Sessions") } }, navigationIcon = { IconButton(onClick = onSessions) { Icon(Icons.AutoMirrored.Filled.List, "Sessions") } },
actions = { 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") } IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Einstellungen") }
}, },
) )
@ -56,7 +64,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
} }
state.error?.let { state.error?.let {
Card( 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), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) { Text(it, Modifier.padding(12.dp)) } ) { Text(it, Modifier.padding(12.dp)) }
} }
@ -71,7 +79,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
enabled = state.settings.isConfigured, enabled = state.settings.isConfigured,
) )
IconButton( IconButton(
onClick = { vm.send(input); input = "" }, onClick = { onSend(input); input = "" },
enabled = !state.busy && input.isNotBlank() && state.settings.isConfigured, enabled = !state.busy && input.isNotBlank() && state.settings.isConfigured,
) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") } ) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") }
} }
@ -105,8 +113,14 @@ private fun Bubble(line: ChatLine) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen: () -> Unit) { fun SessionsScreen(
LaunchedEffect(Unit) { vm.refreshSessions() } state: UiState,
onRefresh: () -> Unit,
onOpen: (String) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit,
) {
LaunchedEffect(Unit) { onRefresh() }
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar(title = { Text("Sessions") }, TopAppBar(title = { Text("Sessions") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } }) 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( ListItem(
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1) }, headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1) },
supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") }, supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
trailingContent = { IconButton(onClick = { vm.deleteSession(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } }, trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
modifier = Modifier.clickable { vm.openSession(s.id); onOpen() }, modifier = Modifier.clickable { onOpen(s.id) },
) )
HorizontalDivider() HorizontalDivider()
} }
@ -130,10 +144,16 @@ fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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 url by remember { mutableStateOf(state.settings.serverUrl) }
var token by remember { mutableStateOf(state.settings.token) } 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) val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") }, TopAppBar(title = { Text("Einstellungen") },
@ -151,10 +171,10 @@ fun SettingsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit) {
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = { Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
vm.saveSettings(ServerSettings(url, token)); onBack() onSave(ServerSettings(url, token)); onBack()
}) { Text("Speichern") } }) { Text("Speichern") }
OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = { 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") } }) { Text("Verbindung testen") }
} }
status?.let { Text(it) } 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)
}

View file

@ -0,0 +1,77 @@
package dev.byteclaw.client.preview
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import com.android.resources.NightMode
import dev.byteclaw.client.data.ServerSettings
import dev.byteclaw.client.data.Session
import dev.byteclaw.client.ui.ByteClawTheme
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.UiState
import androidx.compose.runtime.Composable
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
/** Renders the main screens with DEMO data (no server, no emulator). Record: ./gradlew recordPaparazziDebug */
@RunWith(Parameterized::class)
class PreviewScreenshots(private val dark: Boolean) {
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}")
fun modes() = listOf(arrayOf<Any>(false), arrayOf<Any>(true))
}
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6.copy(nightMode = if (dark) NightMode.NIGHT else NightMode.NOTNIGHT),
maxPercentDifference = 0.1,
)
private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real")
private fun snap(name: String, content: @Composable () -> Unit) =
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) }
@Test fun chat() = snap("chat") {
ChatScreen(
state = UiState(
settings = settings, sessionId = "demo", busy = true,
lines = listOf(
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
ChatLine("tool", "{\"timezone\":\"Europe/Berlin\"}", "time_now"),
ChatLine("tool", "{\"url\":\"https://go.dev\"}", "web_fetch"),
ChatLine("assistant", "In Berlin ist es gerade 04:32 Uhr (Sonntag). Auf go.dev steht: „Build simple, secure, scalable systems with Go“ – die Seite verlinkt"),
),
),
onSend = {}, onNewChat = {}, onDismissError = {}, onSessions = {}, onSettings = {},
initialInput = "Fass das bitte kurz zusammen",
)
}
@Test fun sessions() = snap("sessions") {
SessionsScreen(
state = UiState(
settings = settings,
sessions = listOf(
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
Session("2", "[DEMO] Einkaufsliste für das Wochenende", "2026-10-24T18:05:00Z"),
Session("3", "[DEMO] Zusammenfassung README.md", "2026-10-23T09:41:00Z"),
Session("4", "[DEMO] Ideen für den Android-Client", "2026-10-21T20:15:00Z"),
),
),
onRefresh = {}, onOpen = {}, onDelete = {}, onBack = {},
)
}
@Test fun settings() = snap("settings") {
SettingsScreen(
state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View file

@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.paparazzi) apply false
} }

View file

@ -9,6 +9,7 @@ okhttp = "4.12.0"
serialization = "1.7.3" serialization = "1.7.3"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
coreKtx = "1.15.0" coreKtx = "1.15.0"
paparazzi = "1.3.5"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -32,3 +33,4 @@ android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }

BIN
preview/chat_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
preview/chat_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
preview/sessions_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
preview/sessions_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
preview/settings_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
preview/settings_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB