Skip to content

Commit 2b268ff

Browse files
0xadam-brownOpenCode
andcommitted
feat(android): Refine Buddy live feed
Update the Buddy live feed so attention items are explicitly dismissed instead of marked seen on open. Keep the attention outline fixed while the item slides away and the replacement art slides in, and fade the header during dismissal. Put timeline items inside a single light bordered container with regular-weight row text. Co-Authored-By: OpenCode <noreply@opencode.ai>
1 parent 532be2c commit 2b268ff

2 files changed

Lines changed: 232 additions & 80 deletions

File tree

‎sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,14 @@ public constructor(
208208
}
209209

210210
internal fun openLiveFeed() {
211-
liveFeed = safeMarkLiveFeedSeen()
211+
liveFeed = safeLiveFeed()
212212
state = SentryBuddySessionState.LiveFeed
213213
}
214214

215+
internal fun dismissLiveFeedAttention() {
216+
liveFeed = safeMarkLiveFeedSeen()
217+
}
218+
215219
public fun close() {
216220
if (state !is SentryBuddySessionState.Recording) {
217221
state = SentryBuddySessionState.Closed

‎sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt‎

Lines changed: 227 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import androidx.compose.ui.unit.IntOffset
8787
import androidx.compose.ui.unit.dp
8888
import java.util.Locale
8989
import kotlin.math.PI
90+
import kotlin.math.abs
9091
import kotlin.math.roundToInt
9192
import kotlin.math.sin
9293
import kotlinx.coroutines.Dispatchers
@@ -732,7 +733,14 @@ private fun LiveFeedSheet(
732733
onDispatch: (SentryBuddySessionController.() -> Unit) -> Unit,
733734
) {
734735
SheetTitle("Sentry Buddy", "Live Feed")
735-
AttentionCard(liveFeed, sentryUiLinks, nowMs)
736+
val emptyAttentionArtIndex = remember { EmptyAttentionArtIndex.next() }
737+
AttentionCard(
738+
liveFeed = liveFeed,
739+
sentryUiLinks = sentryUiLinks,
740+
nowMs = nowMs,
741+
emptyArtIndex = emptyAttentionArtIndex,
742+
onDismiss = { onDispatch { dismissLiveFeedAttention() } },
743+
)
736744
Button(
737745
modifier = Modifier.fillMaxWidth().height(56.dp),
738746
colors = ButtonDefaults.buttonColors(containerColor = BuddyPurple),
@@ -754,86 +762,206 @@ private fun LiveFeedSheet(
754762
}
755763

756764
@Composable
757-
private fun AttentionCard(liveFeed: BuddyLiveFeed, sentryUiLinks: BuddySentryUiLinks, nowMs: Long) {
765+
private fun AttentionCard(
766+
liveFeed: BuddyLiveFeed,
767+
sentryUiLinks: BuddySentryUiLinks,
768+
nowMs: Long,
769+
emptyArtIndex: Int,
770+
onDismiss: () -> Unit,
771+
) {
772+
val item = liveFeed.latestUnviewedAdverseItem
773+
val dismissOffset = remember(item?.id) { Animatable(0f) }
774+
val headerAlpha =
775+
if (item == null) {
776+
0f
777+
} else {
778+
(1f - (-dismissOffset.value / ATTENTION_HEADER_FADE_DISTANCE_PX)).coerceIn(0f, 1f)
779+
}
758780
Text(
759781
"Needs attention",
760782
style = MaterialTheme.typography.titleMedium,
761783
fontWeight = FontWeight.Bold,
762-
color = BuddyInk,
784+
color = BuddyInk.copy(alpha = headerAlpha),
763785
)
764786

765-
val item = liveFeed.latestAdverseItem
766787
if (item == null) {
767-
Row(
768-
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
769-
horizontalArrangement = Arrangement.SpaceBetween,
770-
verticalAlignment = Alignment.CenterVertically,
788+
Surface(
789+
modifier = Modifier.fillMaxWidth(),
790+
color = Color.Transparent,
791+
shape = RoundedCornerShape(16.dp),
792+
border = CardDefaults.outlinedCardBorder(),
771793
) {
772-
Text("No recent errors or slow spans.", color = BuddyMuted)
773-
Text("OK", color = BuddyPurple, fontWeight = FontWeight.Bold)
794+
EmptyAttentionArt(
795+
index = emptyArtIndex,
796+
modifier = Modifier.fillMaxWidth().height(132.dp).padding(18.dp),
797+
)
774798
}
775799
return
776800
}
777801

778802
val color = severityColor(item.severity)
779803
val context = LocalContext.current
780804
val link = sentryUiLinks.linkFor(item)
781-
Card(
782-
modifier =
783-
Modifier.clickable(enabled = link != null) { link?.let { openSentryLink(context, it) } },
784-
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.10f)),
805+
val dismissScope = rememberCoroutineScope()
806+
Surface(
807+
modifier = Modifier.fillMaxWidth(),
808+
color = Color.Transparent,
809+
shape = RoundedCornerShape(16.dp),
810+
border = CardDefaults.outlinedCardBorder(),
785811
) {
786-
Column(
787-
modifier = Modifier.fillMaxWidth().padding(16.dp),
788-
verticalArrangement = Arrangement.spacedBy(10.dp),
812+
BoxWithConstraints(
813+
modifier =
814+
Modifier.fillMaxWidth().heightIn(min = 132.dp).pointerInput(item.id) {
815+
detectDragGestures(
816+
onDragEnd = {
817+
val dismissDistance = size.width.toFloat()
818+
val shouldDismiss = abs(dismissOffset.value) > dismissDistance * 0.35f
819+
dismissScope.launch {
820+
if (shouldDismiss) {
821+
dismissOffset.animateTo(-dismissDistance)
822+
onDismiss()
823+
} else {
824+
dismissOffset.animateTo(0f)
825+
}
826+
}
827+
},
828+
onDragCancel = { dismissScope.launch { dismissOffset.animateTo(0f) } },
829+
) { change, dragAmount ->
830+
change.consume()
831+
val dismissDistance = size.width.toFloat()
832+
val nextOffset = (dismissOffset.value + dragAmount.x).coerceIn(-dismissDistance, 0f)
833+
dismissScope.launch { dismissOffset.snapTo(nextOffset) }
834+
}
835+
}
789836
) {
790-
Row(
791-
modifier = Modifier.fillMaxWidth(),
792-
horizontalArrangement = Arrangement.spacedBy(10.dp),
793-
verticalAlignment = Alignment.CenterVertically,
837+
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
838+
Box(
839+
modifier =
840+
Modifier.matchParentSize().offset {
841+
IntOffset((dismissOffset.value + widthPx).roundToInt(), 0)
842+
}
794843
) {
795-
LiveFeedCategoryPill(item.category.label, color)
796-
Text(
797-
item.title(),
798-
modifier = Modifier.weight(1f),
799-
color = BuddyInk,
800-
style = MaterialTheme.typography.titleMedium,
801-
fontWeight = FontWeight.Bold,
802-
)
803-
Text(
804-
relativeTime(item.timestamp.time, nowMs),
805-
color = BuddyMuted,
806-
style = MaterialTheme.typography.labelMedium,
844+
EmptyAttentionArt(
845+
index = emptyArtIndex,
846+
modifier = Modifier.fillMaxSize().padding(18.dp),
807847
)
808848
}
809-
item.screenContextText()?.let { screenContext ->
810-
Text(
811-
screenContext,
812-
color = BuddyMuted,
813-
style = MaterialTheme.typography.bodySmall,
849+
Box(
850+
modifier =
851+
Modifier.matchParentSize()
852+
.offset { IntOffset(dismissOffset.value.roundToInt(), 0) }
853+
.clickable(enabled = link != null) { link?.let { openSentryLink(context, it) } }
854+
) {
855+
AttentionItemContent(
856+
item = item,
857+
liveFeed = liveFeed,
858+
color = color,
859+
nowMs = nowMs,
814860
)
815861
}
816-
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
817-
adverseCountChips(liveFeed).forEach { chip ->
818-
Surface(
819-
color = Color.White,
820-
shape = RoundedCornerShape(16.dp),
821-
border = CardDefaults.outlinedCardBorder(),
822-
) {
823-
Text(
824-
chip,
825-
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
826-
color = BuddyInk,
827-
style = MaterialTheme.typography.labelMedium,
828-
fontWeight = FontWeight.Bold,
829-
)
830-
}
862+
}
863+
}
864+
}
865+
866+
@Composable
867+
private fun AttentionItemContent(
868+
item: BuddyLiveFeedItem,
869+
liveFeed: BuddyLiveFeed,
870+
color: Color,
871+
nowMs: Long,
872+
) {
873+
Column(
874+
modifier = Modifier.fillMaxWidth().padding(16.dp),
875+
verticalArrangement = Arrangement.spacedBy(10.dp),
876+
) {
877+
Row(
878+
modifier = Modifier.fillMaxWidth(),
879+
horizontalArrangement = Arrangement.spacedBy(10.dp),
880+
verticalAlignment = Alignment.CenterVertically,
881+
) {
882+
LiveFeedCategoryPill(item.category.label, color)
883+
Text(
884+
item.title(),
885+
modifier = Modifier.weight(1f),
886+
color = BuddyInk,
887+
style = MaterialTheme.typography.titleMedium,
888+
fontWeight = FontWeight.Normal,
889+
)
890+
Text(
891+
relativeTime(item.timestamp.time, nowMs),
892+
color = BuddyMuted,
893+
style = MaterialTheme.typography.labelMedium,
894+
fontWeight = FontWeight.Normal,
895+
)
896+
}
897+
item.screenContextText()?.let { screenContext ->
898+
Text(
899+
screenContext,
900+
color = BuddyMuted,
901+
style = MaterialTheme.typography.bodySmall,
902+
fontWeight = FontWeight.Normal,
903+
)
904+
}
905+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
906+
adverseCountChips(liveFeed).forEach { chip ->
907+
Surface(
908+
color = Color.White,
909+
shape = RoundedCornerShape(16.dp),
910+
border = CardDefaults.outlinedCardBorder(),
911+
) {
912+
Text(
913+
chip,
914+
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
915+
color = BuddyInk,
916+
style = MaterialTheme.typography.labelMedium,
917+
fontWeight = FontWeight.Normal,
918+
)
831919
}
832920
}
833921
}
834922
}
835923
}
836924

925+
@Composable
926+
private fun EmptyAttentionArt(index: Int, modifier: Modifier = Modifier) {
927+
Canvas(modifier = modifier) {
928+
val variant = index % EMPTY_ATTENTION_ART_VARIANTS
929+
val palette =
930+
when (variant % 5) {
931+
0 -> listOf(BuddyPurple, BuddyRed, BuddyGold)
932+
1 -> listOf(BuddyRed, BuddyPurple, BuddyMuted)
933+
2 -> listOf(BuddyGold, BuddyPurple, BuddyRed)
934+
3 -> listOf(BuddyPurple, BuddyMuted, BuddyGold)
935+
else -> listOf(BuddyMuted, BuddyRed, BuddyPurple)
936+
}
937+
val w = size.width
938+
val h = size.height
939+
val center = Offset(w * (0.46f + (variant % 3) * 0.04f), h * 0.50f)
940+
drawCircle(palette[0].copy(alpha = 0.12f), radius = h * 0.46f, center = center)
941+
drawCircle(
942+
palette[1].copy(alpha = 0.16f),
943+
radius = h * 0.26f,
944+
center = Offset(w * 0.68f, h * 0.34f),
945+
)
946+
drawCircle(
947+
palette[2].copy(alpha = 0.18f),
948+
radius = h * 0.18f,
949+
center = Offset(w * 0.28f, h * 0.72f),
950+
)
951+
val glyph =
952+
Path().apply {
953+
moveTo(w * 0.45f, h * 0.18f)
954+
lineTo(w * 0.27f, h * 0.74f)
955+
lineTo(w * 0.72f, h * 0.74f)
956+
close()
957+
}
958+
drawPath(glyph, palette[0].copy(alpha = 0.20f))
959+
drawLine(palette[0], Offset(w * 0.38f, h * 0.58f), Offset(w * 0.58f, h * 0.58f), 5f)
960+
drawLine(palette[1], Offset(w * 0.42f, h * 0.46f), Offset(w * 0.62f, h * 0.46f), 4f)
961+
drawLine(palette[2], Offset(w * 0.46f, h * 0.34f), Offset(w * 0.66f, h * 0.34f), 3f)
962+
}
963+
}
964+
837965
@Composable
838966
private fun EmptyLiveFeedCard() {
839967
Card(border = CardDefaults.outlinedCardBorder()) {
@@ -852,31 +980,39 @@ private fun LiveFeedRows(
852980
nowMs: Long,
853981
) {
854982
val context = LocalContext.current
855-
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
856-
items.forEach { item ->
857-
val color =
858-
if (item.adverse) severityColor(item.severity) else timelineColor(item.timelineItem)
859-
val link = sentryUiLinks.linkFor(item)
860-
Row(
861-
modifier =
862-
Modifier.fillMaxWidth()
863-
.clickable(enabled = link != null) { link?.let { openSentryLink(context, it) } }
864-
.padding(vertical = 7.dp),
865-
horizontalArrangement = Arrangement.spacedBy(10.dp),
866-
verticalAlignment = Alignment.CenterVertically,
867-
) {
868-
LiveFeedCategoryPill(item.category.label, color)
869-
Text(
870-
item.title(),
871-
modifier = Modifier.weight(1f),
872-
color = BuddyInk,
873-
fontWeight = if (item.adverse) FontWeight.Bold else FontWeight.Normal,
874-
)
875-
Text(
876-
relativeTime(item.timestamp.time, nowMs),
877-
color = BuddyMuted,
878-
style = MaterialTheme.typography.labelMedium,
879-
)
983+
Surface(
984+
modifier = Modifier.fillMaxWidth(),
985+
color = Color.Transparent,
986+
shape = RoundedCornerShape(16.dp),
987+
border = CardDefaults.outlinedCardBorder(),
988+
) {
989+
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
990+
items.forEach { item ->
991+
val color =
992+
if (item.adverse) severityColor(item.severity) else timelineColor(item.timelineItem)
993+
val link = sentryUiLinks.linkFor(item)
994+
Row(
995+
modifier =
996+
Modifier.fillMaxWidth()
997+
.clickable(enabled = link != null) { link?.let { openSentryLink(context, it) } }
998+
.padding(vertical = 7.dp),
999+
horizontalArrangement = Arrangement.spacedBy(10.dp),
1000+
verticalAlignment = Alignment.CenterVertically,
1001+
) {
1002+
LiveFeedCategoryPill(item.category.label, color)
1003+
Text(
1004+
item.title(),
1005+
modifier = Modifier.weight(1f),
1006+
color = BuddyInk,
1007+
fontWeight = FontWeight.Normal,
1008+
)
1009+
Text(
1010+
relativeTime(item.timestamp.time, nowMs),
1011+
color = BuddyMuted,
1012+
style = MaterialTheme.typography.labelMedium,
1013+
fontWeight = FontWeight.Normal,
1014+
)
1015+
}
8801016
}
8811017
}
8821018
}
@@ -890,11 +1026,21 @@ private fun LiveFeedCategoryPill(label: String, color: Color) {
8901026
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
8911027
color = color,
8921028
style = MaterialTheme.typography.labelSmall,
893-
fontWeight = FontWeight.Bold,
1029+
fontWeight = FontWeight.Normal,
8941030
)
8951031
}
8961032
}
8971033

1034+
private object EmptyAttentionArtIndex {
1035+
private var nextIndex = 0
1036+
1037+
fun next(): Int {
1038+
val index = nextIndex
1039+
nextIndex = (nextIndex + 1) % EMPTY_ATTENTION_ART_VARIANTS
1040+
return index
1041+
}
1042+
}
1043+
8981044
@Composable
8991045
private fun SentryBuddyGlyph(tint: Color, modifier: Modifier = Modifier) {
9001046
Icon(
@@ -1427,6 +1573,8 @@ private val BuddyBubbleTouchPadding = 20.dp
14271573
private val BuddyTransientTextWidth = 190.dp
14281574
private val BuddyTransientTextHeight = 28.dp
14291575
private const val LIVE_FEED_VISIBLE_ITEM_LIMIT = 7
1576+
private const val EMPTY_ATTENTION_ART_VARIANTS = 10
1577+
private const val ATTENTION_HEADER_FADE_DISTANCE_PX = 180f
14301578
private const val ANALYSIS_POLL_INTERVAL_MS = 1000L
14311579
private const val ANALYSIS_TIMEOUT_MS = 30_000L
14321580

0 commit comments

Comments
 (0)