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.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,34 +192,77 @@ fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Row(
|
if (query.isNotBlank()) {
|
||||||
Modifier
|
Row(
|
||||||
.clickable { expanded = !expanded }
|
Modifier.padding(bottom = 6.dp),
|
||||||
.padding(vertical = 4.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
) {
|
||||||
) {
|
Icon(KaizenIcons.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(13.dp))
|
||||||
Icon(KaizenIcons.Globe, null, tint = cs.onSurfaceVariant.copy(alpha = 0.55f), modifier = Modifier.size(14.dp))
|
Spacer(Modifier.width(5.dp))
|
||||||
Spacer(Modifier.width(6.dp))
|
Text(
|
||||||
Text(
|
query,
|
||||||
stringResource(R.string.stream_sources) + " (${sources.size})",
|
color = cs.onSurfaceVariant.copy(alpha = 0.55f),
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.55f),
|
fontSize = 12.sp,
|
||||||
fontSize = 12.sp,
|
fontWeight = FontWeight.W300,
|
||||||
fontWeight = FontWeight.Medium,
|
maxLines = 1,
|
||||||
)
|
overflow = TextOverflow.Ellipsis,
|
||||||
Spacer(Modifier.width(4.dp))
|
)
|
||||||
Icon(
|
}
|
||||||
if (expanded) KaizenIcons.ChevronUp else KaizenIcons.ChevronDown,
|
|
||||||
null, tint = cs.onSurfaceVariant.copy(alpha = 0.35f), modifier = Modifier.size(13.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) {
|
Row(
|
||||||
sources.forEach { source ->
|
Modifier.horizontalScroll(rememberScrollState()),
|
||||||
SourceCard(source, isDark, accent) {
|
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 {
|
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) { }
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sources.any { it.title.isNotBlank() || it.snippet.isNotBlank() }) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.clickable { expanded = !expanded }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
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(3.dp))
|
||||||
|
Icon(
|
||||||
|
if (expanded) KaizenIcons.ChevronUp else KaizenIcons.ChevronDown,
|
||||||
|
null, tint = cs.onSurfaceVariant.copy(alpha = 0.35f), modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) {
|
||||||
|
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 {
|
||||||
|
context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(source.url)))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,58 +271,105 @@ 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(
|
|
||||||
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 domain = remember(source.url) {
|
Row(
|
||||||
try {
|
|
||||||
java.net.URI(source.url).host?.removePrefix("www.") ?: source.url
|
|
||||||
} catch (_: Exception) { source.url }
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.clip(KaizenShapes.pill)
|
||||||
.clip(KaizenShapes.md)
|
.background(chipBg)
|
||||||
.background(bg)
|
.border(0.6.dp, chipBorder, KaizenShapes.pill)
|
||||||
.border(0.8.dp, borderBrush, KaizenShapes.md)
|
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
.padding(start = 3.dp, end = 10.dp, top = 5.dp, bottom = 5.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Box(
|
||||||
source.title.ifEmpty { domain },
|
Modifier
|
||||||
color = cs.onBackground,
|
.size(18.dp)
|
||||||
fontSize = 13.sp,
|
.clip(KaizenShapes.circle)
|
||||||
fontWeight = FontWeight.Medium,
|
.background(color.copy(alpha = if (isDark) 0.30f else 0.20f)),
|
||||||
maxLines = 2,
|
contentAlignment = Alignment.Center,
|
||||||
overflow = TextOverflow.Ellipsis,
|
) {
|
||||||
)
|
Text(
|
||||||
Spacer(Modifier.height(2.dp))
|
"$index",
|
||||||
|
color = if (isDark) Color.White.copy(alpha = 0.90f) else color,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.W600,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
domain,
|
domain,
|
||||||
color = accent.primary.copy(alpha = 0.65f),
|
color = cs.onSurfaceVariant.copy(alpha = 0.70f),
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.W400,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.widthIn(max = 140.dp),
|
||||||
)
|
)
|
||||||
if (source.snippet.isNotBlank()) {
|
}
|
||||||
Spacer(Modifier.height(3.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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(KaizenShapes.sm)
|
||||||
|
.background(bg)
|
||||||
|
.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.W400,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
source.snippet,
|
domain,
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.50f),
|
color = color.copy(alpha = 0.70f),
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
maxLines = 2,
|
fontWeight = FontWeight.W300,
|
||||||
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
if (source.snippet.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(3.dp))
|
||||||
|
Text(
|
||||||
|
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 -->
|
<!-- 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue