test(android): Paparazzi preview screenshots with demo data; stateless screen composables
This commit is contained in:
parent
7b92e259ad
commit
1e32997504
20 changed files with 141 additions and 17 deletions
|
|
@ -3,6 +3,7 @@ plugins {
|
|||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.paparazzi) // screenshot tests only (JVM, test source set)
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
11
android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt
Normal file
11
android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt
Normal 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)
|
||||
}
|
||||
|
|
@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Loading…
Add table
Add a link
Reference in a new issue