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:
parent
24d6b2148a
commit
77d2d8e5df
3 changed files with 172 additions and 64 deletions
|
|
@ -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,34 +192,77 @@ 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)
|
||||
} catch (_: Exception) { }
|
||||
context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(source.url)))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -214,46 +271,90 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue