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 package industries.geesawra.monarch 2 3 import androidx.compose.foundation.Image 4 import androidx.compose.foundation.border 5 import androidx.compose.foundation.layout.Arrangement 6 import androidx.compose.foundation.layout.Box 7 import androidx.compose.foundation.layout.Column ··· 18 import androidx.compose.material3.Surface 19 import androidx.compose.material3.Text 20 import androidx.compose.runtime.Composable 21 import androidx.compose.ui.Alignment 22 import androidx.compose.ui.Modifier 23 import androidx.compose.ui.draw.clip ··· 26 import androidx.compose.ui.platform.LocalContext 27 import androidx.compose.ui.text.font.FontWeight 28 import androidx.compose.ui.text.style.TextAlign 29 import androidx.compose.ui.unit.dp 30 import coil3.compose.AsyncImage 31 import coil3.request.ImageRequest 32 import coil3.request.crossfade ··· 35 import nl.jacobras.humanreadable.HumanReadable 36 import kotlin.time.ExperimentalTime 37 38 @OptIn(ExperimentalTime::class) 39 @Composable 40 fun LikeRepostRowView( 41 modifier: Modifier = Modifier, 42 data: RepeatedNotification 43 - 44 ) { 45 val minSize = 24.dp 46 47 Surface( 48 color = Color.Transparent, 49 modifier = modifier 50 .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) 51 .fillMaxWidth() 52 ) { 53 54 Column( 55 verticalArrangement = Arrangement.spacedBy(4.dp) 56 ) { 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", 65 modifier = Modifier 66 - .size( 67 - when (data.kind) { 68 - RepeatableNotification.Like -> minSize + 8.dp 69 - RepeatableNotification.Repost -> minSize 70 } 71 - ) 72 - .offset( 73 - x = when (idx) { 74 - 0 -> 0.dp 75 - else -> (idx * 16).dp 76 } 77 - ) 78 - .border( 79 - width = 1.dp, 80 - color = MaterialTheme.colorScheme.surface, 81 - shape = CircleShape 82 ) 83 - .clip(CircleShape) 84 - ) 85 } 86 } 87 ··· 114 ) 115 116 val authors = data.authors 117 - val firstAuthorName = 118 - authors.first().author.displayName ?: authors.first().author.handle 119 val remainingCount = authors.size - 1 120 val text = when { 121 remainingCount > 1 -> "$firstAuthorName and $remainingCount others ${
··· 1 package industries.geesawra.monarch 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 11 import androidx.compose.foundation.Image 12 import androidx.compose.foundation.border 13 + import androidx.compose.foundation.clickable 14 import androidx.compose.foundation.layout.Arrangement 15 import androidx.compose.foundation.layout.Box 16 import androidx.compose.foundation.layout.Column ··· 27 import androidx.compose.material3.Surface 28 import androidx.compose.material3.Text 29 import androidx.compose.runtime.Composable 30 + import androidx.compose.runtime.mutableStateOf 31 + import androidx.compose.runtime.remember 32 import androidx.compose.ui.Alignment 33 import androidx.compose.ui.Modifier 34 import androidx.compose.ui.draw.clip ··· 37 import androidx.compose.ui.platform.LocalContext 38 import androidx.compose.ui.text.font.FontWeight 39 import androidx.compose.ui.text.style.TextAlign 40 + import androidx.compose.ui.unit.IntSize 41 import androidx.compose.ui.unit.dp 42 + import app.bsky.actor.ProfileView 43 import coil3.compose.AsyncImage 44 import coil3.request.ImageRequest 45 import coil3.request.crossfade ··· 48 import nl.jacobras.humanreadable.HumanReadable 49 import kotlin.time.ExperimentalTime 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 + 61 @OptIn(ExperimentalTime::class) 62 @Composable 63 fun LikeRepostRowView( 64 modifier: Modifier = Modifier, 65 data: RepeatedNotification 66 ) { 67 val minSize = 24.dp 68 + val showAvatars = remember { mutableStateOf(false) } 69 70 Surface( 71 color = Color.Transparent, 72 modifier = modifier 73 .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) 74 .fillMaxWidth() 75 + .animateContentSize() 76 ) { 77 78 Column( 79 verticalArrangement = Arrangement.spacedBy(4.dp) 80 ) { 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( 103 modifier = Modifier 104 + .clickable { 105 + if (data.authors.count() > 1) { 106 + showAvatars.value = !showAvatars.value 107 } 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 155 } 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) 185 ) 186 + } 187 + } 188 } 189 } 190 ··· 217 ) 218 219 val authors = data.authors 220 + val firstAuthor = authors.first() 221 + val firstAuthorName = name(firstAuthor.author) 222 val remainingCount = authors.size - 1 223 val text = when { 224 remainingCount > 1 -> "$firstAuthorName and $remainingCount others ${