A cheap attempt at a native Bluesky client for Android

LikeRepostRowView: Expand to show list of users

Allow tapping the row of avatars to expand into a full list showing each user's avatar and name. Tapping again collapses the list back to the stacked avatar view. This only applies if there is more than one user who has liked or reposted.

+130 -27
+130 -27
app/src/main/java/industries/geesawra/monarch/LikeRepostRowView.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 + import androidx.compose.animation.AnimatedContent 4 + import androidx.compose.animation.SizeTransform 5 + import androidx.compose.animation.animateContentSize 6 + import androidx.compose.animation.core.keyframes 7 + import androidx.compose.animation.core.tween 8 + import androidx.compose.animation.fadeIn 9 + import androidx.compose.animation.fadeOut 10 + import androidx.compose.animation.togetherWith 3 11 import androidx.compose.foundation.Image 4 12 import androidx.compose.foundation.border 13 + import androidx.compose.foundation.clickable 5 14 import androidx.compose.foundation.layout.Arrangement 6 15 import androidx.compose.foundation.layout.Box 7 16 import androidx.compose.foundation.layout.Column ··· 18 27 import androidx.compose.material3.Surface 19 28 import androidx.compose.material3.Text 20 29 import androidx.compose.runtime.Composable 30 + import androidx.compose.runtime.mutableStateOf 31 + import androidx.compose.runtime.remember 21 32 import androidx.compose.ui.Alignment 22 33 import androidx.compose.ui.Modifier 23 34 import androidx.compose.ui.draw.clip ··· 26 37 import androidx.compose.ui.platform.LocalContext 27 38 import androidx.compose.ui.text.font.FontWeight 28 39 import androidx.compose.ui.text.style.TextAlign 40 + import androidx.compose.ui.unit.IntSize 29 41 import androidx.compose.ui.unit.dp 42 + import app.bsky.actor.ProfileView 30 43 import coil3.compose.AsyncImage 31 44 import coil3.request.ImageRequest 32 45 import coil3.request.crossfade ··· 35 48 import nl.jacobras.humanreadable.HumanReadable 36 49 import kotlin.time.ExperimentalTime 37 50 51 + fun name(p: ProfileView): String { 52 + return when (p.displayName) { 53 + null -> p.handle.handle 54 + else -> when (p.displayName!!.isEmpty()) { 55 + true -> p.handle.handle 56 + else -> p.displayName!! 57 + } 58 + } 59 + } 60 + 38 61 @OptIn(ExperimentalTime::class) 39 62 @Composable 40 63 fun LikeRepostRowView( 41 64 modifier: Modifier = Modifier, 42 65 data: RepeatedNotification 43 - 44 66 ) { 45 67 val minSize = 24.dp 68 + val showAvatars = remember { mutableStateOf(false) } 46 69 47 70 Surface( 48 71 color = Color.Transparent, 49 72 modifier = modifier 50 73 .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) 51 74 .fillMaxWidth() 75 + .animateContentSize() 52 76 ) { 53 77 54 78 Column( 55 79 verticalArrangement = Arrangement.spacedBy(4.dp) 56 80 ) { 57 - Box { 58 - data.authors.take(8).forEachIndexed { idx, it -> 59 - AsyncImage( 60 - model = ImageRequest.Builder(LocalContext.current) 61 - .data(it.author.avatar?.uri) 62 - .crossfade(true) 63 - .build(), 64 - contentDescription = "Avatar", 81 + AnimatedContent( 82 + targetState = showAvatars.value, 83 + transitionSpec = { 84 + fadeIn(animationSpec = tween(150, 150)) togetherWith 85 + fadeOut(animationSpec = tween(150)) using 86 + SizeTransform { initialSize, targetSize -> 87 + if (targetState) { 88 + keyframes { 89 + IntSize(targetSize.width, initialSize.height) at 150 90 + durationMillis = 300 91 + } 92 + } else { 93 + keyframes { 94 + IntSize(initialSize.width, targetSize.height) at 150 95 + durationMillis = 300 96 + } 97 + } 98 + } 99 + }, label = "size transform" 100 + ) { 101 + when (it) { 102 + true -> Column( 65 103 modifier = Modifier 66 - .size( 67 - when (data.kind) { 68 - RepeatableNotification.Like -> minSize + 8.dp 69 - RepeatableNotification.Repost -> minSize 104 + .clickable { 105 + if (data.authors.count() > 1) { 106 + showAvatars.value = !showAvatars.value 70 107 } 71 - ) 72 - .offset( 73 - x = when (idx) { 74 - 0 -> 0.dp 75 - else -> (idx * 16).dp 108 + } 109 + ) { 110 + data.authors.take(8).forEachIndexed { idx, it -> 111 + Row( 112 + verticalAlignment = Alignment.CenterVertically, 113 + horizontalArrangement = Arrangement.Start, 114 + modifier = Modifier 115 + .fillMaxWidth() 116 + ) { 117 + AsyncImage( 118 + model = ImageRequest.Builder(LocalContext.current) 119 + .data(it.author.avatar?.uri) 120 + .crossfade(true) 121 + .build(), 122 + contentDescription = "Avatar", 123 + modifier = Modifier 124 + .size( 125 + when (data.kind) { 126 + RepeatableNotification.Like -> minSize + 8.dp 127 + RepeatableNotification.Repost -> minSize 128 + } 129 + ) 130 + .border( 131 + width = 1.dp, 132 + color = MaterialTheme.colorScheme.surface, 133 + shape = CircleShape 134 + ) 135 + .clip(CircleShape) 136 + ) 137 + 138 + Text( 139 + modifier = Modifier 140 + .fillMaxWidth() 141 + .padding(start = 4.dp), 142 + text = name(it.author), 143 + style = MaterialTheme.typography.bodyMedium, 144 + fontWeight = FontWeight.Bold, 145 + ) 146 + } 147 + } 148 + } 149 + 150 + false -> Box( 151 + modifier = Modifier 152 + .clickable { 153 + if (data.authors.count() > 1) { 154 + showAvatars.value = !showAvatars.value 76 155 } 77 - ) 78 - .border( 79 - width = 1.dp, 80 - color = MaterialTheme.colorScheme.surface, 81 - shape = CircleShape 156 + } 157 + .fillMaxWidth() 158 + ) { 159 + data.authors.take(8).forEachIndexed { idx, it -> 160 + AsyncImage( 161 + model = ImageRequest.Builder(LocalContext.current) 162 + .data(it.author.avatar?.uri) 163 + .crossfade(true) 164 + .build(), 165 + contentDescription = "Avatar", 166 + modifier = Modifier 167 + .size( 168 + when (data.kind) { 169 + RepeatableNotification.Like -> minSize + 8.dp 170 + RepeatableNotification.Repost -> minSize 171 + } 172 + ) 173 + .offset( 174 + x = when (idx) { 175 + 0 -> 0.dp 176 + else -> (idx * 16).dp 177 + } 178 + ) 179 + .border( 180 + width = 1.dp, 181 + color = MaterialTheme.colorScheme.surface, 182 + shape = CircleShape 183 + ) 184 + .clip(CircleShape) 82 185 ) 83 - .clip(CircleShape) 84 - ) 186 + } 187 + } 85 188 } 86 189 } 87 190 ··· 114 217 ) 115 218 116 219 val authors = data.authors 117 - val firstAuthorName = 118 - authors.first().author.displayName ?: authors.first().author.handle 220 + val firstAuthor = authors.first() 221 + val firstAuthorName = name(firstAuthor.author) 119 222 val remainingCount = authors.size - 1 120 223 val text = when { 121 224 remainingCount > 1 -> "$firstAuthorName and $remainingCount others ${