test(android): Paparazzi preview screenshots with demo data; stateless screen composables
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 } }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
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]",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
After Width: | Height: | Size: 42 KiB |
BIN
preview/chat_light.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
preview/sessions_dark.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
preview/sessions_light.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
preview/settings_dark.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
preview/settings_light.png
Normal file
|
After Width: | Height: | Size: 50 KiB |