Redesign SourcesBlock — horizontal chips + detail cards

Before: sources hidden behind a collapsed "Quellen (3)" toggle,
vertical stack of flat cards, requires click to see anything.

After:
- Always-visible horizontal scrollable chips with numbered colored
  circles (indigo/violet/cyan/emerald/amber/red/pink/blue cycling)
- Each chip shows the domain name, tap opens the URL
- Search query shown above sources when available
- "Details anzeigen" expands to full cards with colored left bar,
  title, domain, and snippet
- Perplexity/Gemini-inspired compact visual style
This commit is contained in:
Bruno Deanoz 2026-06-24 10:23:24 +02:00
parent 24d6b2148a
commit 77d2d8e5df
3 changed files with 172 additions and 64 deletions

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -17,6 +18,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -169,6 +172,17 @@ fun ToolEventsBlock(tools: List<ToolEvent>, streaming: Boolean = false) {
}
}
private val SOURCE_COLORS = listOf(
Color(0xFF6366F1), // indigo
Color(0xFF8B5CF6), // violet
Color(0xFF06B6D4), // cyan
Color(0xFF10B981), // emerald
Color(0xFFF59E0B), // amber
Color(0xFFEF4444), // red
Color(0xFFEC4899), // pink
Color(0xFF3B82F6), // blue
)
@Composable
fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
val cs = MaterialTheme.colorScheme
@ -178,33 +192,75 @@ fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
val context = LocalContext.current
Column {
if (query.isNotBlank()) {
Row(
Modifier.padding(bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(KaizenIcons.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(5.dp))
Text(
query,
color = cs.onSurfaceVariant.copy(alpha = 0.55f),
fontSize = 12.sp,
fontWeight = FontWeight.W300,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
sources.forEachIndexed { idx, source ->
val color = SOURCE_COLORS[idx % SOURCE_COLORS.size]
val domain = remember(source.url) {
try { java.net.URI(source.url).host?.removePrefix("www.") ?: source.url }
catch (_: Exception) { source.url }
}
SourceChip(
index = idx + 1,
domain = domain,
color = color,
isDark = isDark,
onClick = {
try {
context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(source.url)))
} catch (_: Exception) {}
},
)
}
}
if (sources.any { it.title.isNotBlank() || it.snippet.isNotBlank() }) {
Spacer(Modifier.height(6.dp))
Row(
Modifier
.clickable { expanded = !expanded }
.padding(vertical = 4.dp),
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(KaizenIcons.Globe, null, tint = cs.onSurfaceVariant.copy(alpha = 0.55f), modifier = Modifier.size(14.dp))
Spacer(Modifier.width(6.dp))
Text(
stringResource(R.string.stream_sources) + " (${sources.size})",
color = cs.onSurfaceVariant.copy(alpha = 0.55f),
fontSize = 12.sp,
if (expanded) stringResource(R.string.stream_sources_hide) else stringResource(R.string.stream_sources_show),
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(3.dp))
Icon(
if (expanded) KaizenIcons.ChevronUp else KaizenIcons.ChevronDown,
null, tint = cs.onSurfaceVariant.copy(alpha = 0.35f), modifier = Modifier.size(13.dp),
null, tint = cs.onSurfaceVariant.copy(alpha = 0.35f), modifier = Modifier.size(12.dp),
)
}
AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) {
sources.forEach { source ->
SourceCard(source, isDark, accent) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
sources.forEachIndexed { idx, source ->
val color = SOURCE_COLORS[idx % SOURCE_COLORS.size]
SourceDetailCard(source, idx + 1, color, isDark) {
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(source.url))
context.startActivity(intent)
context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(source.url)))
} catch (_: Exception) {}
}
}
@ -212,48 +268,93 @@ fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
}
}
}
}
@Composable
private fun SourceCard(source: SearchSource, isDark: Boolean, accent: dev.kaizen.app.ui.theme.themes.KaizenAccentScheme, onClick: () -> Unit) {
private fun SourceChip(index: Int, domain: String, color: Color, isDark: Boolean, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme
val bg = if (isDark) Color.White.copy(alpha = 0.05f) else Color.Black.copy(alpha = 0.03f)
val borderBrush = remember(isDark) {
Brush.verticalGradient(
listOf(
if (isDark) Color.White.copy(alpha = 0.10f) else Color.Black.copy(alpha = 0.06f),
if (isDark) Color.White.copy(alpha = 0.03f) else Color.Black.copy(alpha = 0.02f),
)
val chipBg = color.copy(alpha = if (isDark) 0.12f else 0.08f)
val chipBorder = color.copy(alpha = if (isDark) 0.25f else 0.18f)
Row(
Modifier
.clip(KaizenShapes.pill)
.background(chipBg)
.border(0.6.dp, chipBorder, KaizenShapes.pill)
.clickable(onClick = onClick)
.padding(start = 3.dp, end = 10.dp, top = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(18.dp)
.clip(KaizenShapes.circle)
.background(color.copy(alpha = if (isDark) 0.30f else 0.20f)),
contentAlignment = Alignment.Center,
) {
Text(
"$index",
color = if (isDark) Color.White.copy(alpha = 0.90f) else color,
fontSize = 10.sp,
fontWeight = FontWeight.W600,
)
}
Spacer(Modifier.width(6.dp))
Text(
domain,
color = cs.onSurfaceVariant.copy(alpha = 0.70f),
fontSize = 12.sp,
fontWeight = FontWeight.W400,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 140.dp),
)
}
}
@Composable
private fun SourceDetailCard(source: SearchSource, index: Int, color: Color, isDark: Boolean, onClick: () -> Unit) {
val cs = MaterialTheme.colorScheme
val bg = if (isDark) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.02f)
val leftBar = color.copy(alpha = if (isDark) 0.50f else 0.40f)
val domain = remember(source.url) {
try {
java.net.URI(source.url).host?.removePrefix("www.") ?: source.url
} catch (_: Exception) { source.url }
try { java.net.URI(source.url).host?.removePrefix("www.") ?: source.url }
catch (_: Exception) { source.url }
}
Column(
Row(
Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.clip(KaizenShapes.sm)
.background(bg)
.border(0.8.dp, borderBrush, KaizenShapes.md)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
.clickable(onClick = onClick),
) {
Box(Modifier.width(3.dp).height(if (source.snippet.isNotBlank()) 60.dp else 40.dp).background(leftBar))
Column(Modifier.padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"$index",
color = color,
fontSize = 11.sp,
fontWeight = FontWeight.W600,
)
Spacer(Modifier.width(6.dp))
Text(
source.title.ifEmpty { domain },
color = cs.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
fontWeight = FontWeight.W400,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.height(2.dp))
}
Text(
domain,
color = accent.primary.copy(alpha = 0.65f),
color = color.copy(alpha = 0.70f),
fontSize = 11.sp,
fontWeight = FontWeight.W300,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -263,9 +364,12 @@ private fun SourceCard(source: SearchSource, isDark: Boolean, accent: dev.kaizen
source.snippet,
color = cs.onSurfaceVariant.copy(alpha = 0.50f),
fontSize = 11.sp,
fontWeight = FontWeight.W300,
lineHeight = 16.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View file

@ -33,6 +33,8 @@
<!-- Stream blocks -->
<string name="stream_reasoning">Reasoning</string>
<string name="stream_sources">Sources</string>
<string name="stream_sources_show">Show details</string>
<string name="stream_sources_hide">Hide details</string>
<string name="stream_tool_thinking">Processing …</string>
<string name="stream_tool_analyzing">Analyzing %1$s …</string>

View file

@ -35,6 +35,8 @@
<!-- Stream blocks -->
<string name="stream_reasoning">Gedankengang</string>
<string name="stream_sources">Quellen</string>
<string name="stream_sources_show">Details anzeigen</string>
<string name="stream_sources_hide">Details ausblenden</string>
<string name="stream_tool_thinking">Verarbeitet …</string>
<string name="stream_tool_analyzing">Analysiert %1$s …</string>