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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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 @Composable
fun SourcesBlock(sources: List<SearchSource>, query: String = "") { fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
@ -178,33 +192,75 @@ fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
val context = LocalContext.current val context = LocalContext.current
Column { 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( Row(
Modifier Modifier
.clickable { expanded = !expanded } .clickable { expanded = !expanded }
.padding(vertical = 4.dp), .padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, 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( Text(
stringResource(R.string.stream_sources) + " (${sources.size})", if (expanded) stringResource(R.string.stream_sources_hide) else stringResource(R.string.stream_sources_show),
color = cs.onSurfaceVariant.copy(alpha = 0.55f), color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 12.sp, fontSize = 11.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(3.dp))
Icon( Icon(
if (expanded) KaizenIcons.ChevronUp else KaizenIcons.ChevronDown, 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()) { AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
sources.forEach { source -> sources.forEachIndexed { idx, source ->
SourceCard(source, isDark, accent) { val color = SOURCE_COLORS[idx % SOURCE_COLORS.size]
SourceDetailCard(source, idx + 1, color, isDark) {
try { try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(source.url)) context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(source.url)))
context.startActivity(intent)
} catch (_: Exception) {} } catch (_: Exception) {}
} }
} }
@ -212,48 +268,93 @@ fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
} }
} }
} }
}
@Composable @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 cs = MaterialTheme.colorScheme
val bg = if (isDark) Color.White.copy(alpha = 0.05f) else Color.Black.copy(alpha = 0.03f) val chipBg = color.copy(alpha = if (isDark) 0.12f else 0.08f)
val borderBrush = remember(isDark) { val chipBorder = color.copy(alpha = if (isDark) 0.25f else 0.18f)
Brush.verticalGradient(
listOf( Row(
if (isDark) Color.White.copy(alpha = 0.10f) else Color.Black.copy(alpha = 0.06f), Modifier
if (isDark) Color.White.copy(alpha = 0.03f) else Color.Black.copy(alpha = 0.02f), .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) { val domain = remember(source.url) {
try { try { java.net.URI(source.url).host?.removePrefix("www.") ?: source.url }
java.net.URI(source.url).host?.removePrefix("www.") ?: source.url catch (_: Exception) { source.url }
} catch (_: Exception) { source.url }
} }
Column( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.md) .clip(KaizenShapes.sm)
.background(bg) .background(bg)
.border(0.8.dp, borderBrush, KaizenShapes.md) .clickable(onClick = onClick),
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
) { ) {
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( Text(
source.title.ifEmpty { domain }, source.title.ifEmpty { domain },
color = cs.onBackground, color = cs.onBackground,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.W400,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
Spacer(Modifier.height(2.dp)) }
Text( Text(
domain, domain,
color = accent.primary.copy(alpha = 0.65f), color = color.copy(alpha = 0.70f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.W300,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@ -263,9 +364,12 @@ private fun SourceCard(source: SearchSource, isDark: Boolean, accent: dev.kaizen
source.snippet, source.snippet,
color = cs.onSurfaceVariant.copy(alpha = 0.50f), color = cs.onSurfaceVariant.copy(alpha = 0.50f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.W300,
lineHeight = 16.sp,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
} }
} }
}

View file

@ -33,6 +33,8 @@
<!-- Stream blocks --> <!-- Stream blocks -->
<string name="stream_reasoning">Reasoning</string> <string name="stream_reasoning">Reasoning</string>
<string name="stream_sources">Sources</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_thinking">Processing …</string>
<string name="stream_tool_analyzing">Analyzing %1$s …</string> <string name="stream_tool_analyzing">Analyzing %1$s …</string>

View file

@ -35,6 +35,8 @@
<!-- Stream blocks --> <!-- Stream blocks -->
<string name="stream_reasoning">Gedankengang</string> <string name="stream_reasoning">Gedankengang</string>
<string name="stream_sources">Quellen</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_thinking">Verarbeitet …</string>
<string name="stream_tool_analyzing">Analysiert %1$s …</string> <string name="stream_tool_analyzing">Analysiert %1$s …</string>