diff --git a/README.md b/README.md index 0ce61af..2c4d630 100644 --- a/README.md +++ b/README.md @@ -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 - Weitere Provider (Anthropic nativ), Abbrechen laufender Antworten über WebSocket - 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`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c6e6ba4..daeb647 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt index 4bda559..aa9acf4 100644 --- a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt +++ b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt @@ -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 } } }) } diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index 56f8a27..fed97a9 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -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(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) } diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt new file mode 100644 index 0000000..253eaa7 --- /dev/null +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Theme.kt @@ -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) +} diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt new file mode 100644 index 0000000..d584b34 --- /dev/null +++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt @@ -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(false), arrayOf(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]", + ) + } +} diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[0]_chat_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[0]_chat_light.png new file mode 100644 index 0000000..bbe3ac8 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[0]_chat_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[1]_chat_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[1]_chat_dark.png new file mode 100644 index 0000000..d6dc432 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_chat[1]_chat_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png new file mode 100644 index 0000000..57c026b Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[0]_sessions_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png new file mode 100644 index 0000000..fc60aa4 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_sessions[1]_sessions_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png new file mode 100644 index 0000000..5dd66b5 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png new file mode 100644 index 0000000..250a66e Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png differ diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 132ad8d..f4a0e27 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.paparazzi) apply false } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c62b2d8..3b5a269 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -9,6 +9,7 @@ okhttp = "4.12.0" serialization = "1.7.3" securityCrypto = "1.1.0-alpha06" coreKtx = "1.15.0" +paparazzi = "1.3.5" [libraries] 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-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" } diff --git a/preview/chat_dark.png b/preview/chat_dark.png new file mode 100644 index 0000000..d6dc432 Binary files /dev/null and b/preview/chat_dark.png differ diff --git a/preview/chat_light.png b/preview/chat_light.png new file mode 100644 index 0000000..bbe3ac8 Binary files /dev/null and b/preview/chat_light.png differ diff --git a/preview/sessions_dark.png b/preview/sessions_dark.png new file mode 100644 index 0000000..fc60aa4 Binary files /dev/null and b/preview/sessions_dark.png differ diff --git a/preview/sessions_light.png b/preview/sessions_light.png new file mode 100644 index 0000000..57c026b Binary files /dev/null and b/preview/sessions_light.png differ diff --git a/preview/settings_dark.png b/preview/settings_dark.png new file mode 100644 index 0000000..250a66e Binary files /dev/null and b/preview/settings_dark.png differ diff --git a/preview/settings_light.png b/preview/settings_light.png new file mode 100644 index 0000000..5dd66b5 Binary files /dev/null and b/preview/settings_light.png differ