A cheap attempt at a native Bluesky client for Android

Notifications: hydrate Reposts, abstract Like/Repost row, show proper icons

+169 -74
+162 -64
app/src/main/java/industries/geesawra/monarch/LikeRepostRowView.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 + import androidx.compose.foundation.Image 4 + import androidx.compose.foundation.border 3 5 import androidx.compose.foundation.layout.Arrangement 6 + import androidx.compose.foundation.layout.Box 4 7 import androidx.compose.foundation.layout.Column 5 8 import androidx.compose.foundation.layout.Row 6 9 import androidx.compose.foundation.layout.fillMaxWidth 10 + import androidx.compose.foundation.layout.offset 7 11 import androidx.compose.foundation.layout.padding 8 12 import androidx.compose.foundation.layout.size 9 13 import androidx.compose.foundation.layout.wrapContentHeight 10 14 import androidx.compose.foundation.shape.CircleShape 15 + import androidx.compose.material.icons.Icons 16 + import androidx.compose.material.icons.filled.Repeat 11 17 import androidx.compose.material3.MaterialTheme 12 18 import androidx.compose.material3.Surface 13 19 import androidx.compose.material3.Text ··· 16 22 import androidx.compose.ui.Modifier 17 23 import androidx.compose.ui.draw.clip 18 24 import androidx.compose.ui.graphics.Color 25 + import androidx.compose.ui.graphics.ColorFilter 26 + import androidx.compose.ui.graphics.SolidColor 27 + import androidx.compose.ui.graphics.vector.ImageVector 28 + import androidx.compose.ui.graphics.vector.path 19 29 import androidx.compose.ui.platform.LocalContext 20 30 import androidx.compose.ui.text.font.FontWeight 21 31 import androidx.compose.ui.text.style.TextAlign ··· 35 45 data: RepeatedNotification 36 46 37 47 ) { 38 - val minSize = 55.dp 48 + val minSize = 24.dp 39 49 40 50 Surface( 41 51 color = Color.Transparent, ··· 43 53 .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) 44 54 .fillMaxWidth() 45 55 ) { 46 - Row( 47 - verticalAlignment = Alignment.Top, 48 - horizontalArrangement = Arrangement.Start, 49 - modifier = Modifier 50 - .fillMaxWidth() 51 - .wrapContentHeight() 56 + 57 + Column( 58 + verticalArrangement = Arrangement.spacedBy(4.dp) 52 59 ) { 53 - AsyncImage( 54 - model = ImageRequest.Builder(LocalContext.current) 55 - .data(data.authors.first().author.avatar?.uri) 56 - .crossfade(true) 57 - .build(), 58 - contentDescription = "Avatar", 59 - modifier = Modifier 60 - .size(minSize) 61 - .clip(CircleShape) 62 - ) 63 - 64 - val authors = data.authors 65 - val firstAuthorName = 66 - authors.first().author.displayName ?: authors.first().author.handle 67 - val remainingCount = authors.size - 1 68 - val text = when { 69 - remainingCount > 1 -> "$firstAuthorName and $remainingCount others ${ 70 - when (data.kind) { 71 - RepeatableNotification.Like -> "liked" 72 - RepeatableNotification.Repost -> "reposted" 73 - } 74 - } this" 75 - 76 - remainingCount == 1 -> "$firstAuthorName and 1 other ${ 77 - when (data.kind) { 78 - RepeatableNotification.Like -> "liked" 79 - RepeatableNotification.Repost -> "reposted" 80 - } 81 - } this" 82 - 83 - else -> "$firstAuthorName ${ 84 - when (data.kind) { 85 - RepeatableNotification.Like -> "liked" 86 - RepeatableNotification.Repost -> "reposted" 87 - } 88 - } this" 60 + Box { 61 + data.authors.take(8).forEachIndexed { idx, it -> 62 + AsyncImage( 63 + model = ImageRequest.Builder(LocalContext.current) 64 + .data(it.author.avatar?.uri) 65 + .crossfade(true) 66 + .build(), 67 + contentDescription = "Avatar", 68 + modifier = Modifier 69 + .size(minSize) 70 + .offset( 71 + x = when (idx) { 72 + 0 -> 0.dp 73 + else -> (idx * 16).dp 74 + } 75 + ) 76 + .border( 77 + width = 1.dp, 78 + color = MaterialTheme.colorScheme.surface, 79 + shape = CircleShape 80 + ) 81 + .clip(CircleShape) 82 + ) 83 + } 89 84 } 90 85 91 - Column( 86 + Row( 87 + verticalAlignment = Alignment.Top, 88 + horizontalArrangement = Arrangement.Start, 92 89 modifier = Modifier 93 - .weight(1f) 94 - .padding(start = 16.dp) 90 + .fillMaxWidth() 91 + .wrapContentHeight() 95 92 ) { 96 - Text( 97 - modifier = Modifier.fillMaxWidth(), 98 - text = text, 99 - style = MaterialTheme.typography.bodyMedium, 100 - fontWeight = FontWeight.Bold, 101 - ) 93 + Image( 94 + imageVector = when (data.kind) { 95 + RepeatableNotification.Like -> { 96 + HeartFilled 97 + } 102 98 103 - Text( 104 - text = HumanReadable.timeAgo(data.timestamp), 105 - color = MaterialTheme.colorScheme.onSurfaceVariant, 106 - style = MaterialTheme.typography.bodySmall, 107 - textAlign = TextAlign.End, 108 - modifier = Modifier.fillMaxWidth() 99 + RepeatableNotification.Repost -> { 100 + Icons.Default.Repeat 101 + } 102 + }, 103 + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), 104 + contentDescription = "${ 105 + when (data.kind) { 106 + RepeatableNotification.Like -> "Like" 107 + RepeatableNotification.Repost -> "Repost" 108 + } 109 + } icon", 110 + modifier = Modifier 111 + .size(minSize) 112 + .clip(CircleShape) 109 113 ) 110 114 111 - Text( 112 - modifier = Modifier.fillMaxWidth(), 113 - text = data.post.text, 114 - color = MaterialTheme.colorScheme.secondary, 115 - style = MaterialTheme.typography.bodySmall, 116 - ) 115 + val authors = data.authors 116 + val firstAuthorName = 117 + authors.first().author.displayName ?: authors.first().author.handle 118 + val remainingCount = authors.size - 1 119 + val text = when { 120 + remainingCount > 1 -> "$firstAuthorName and $remainingCount others ${ 121 + when (data.kind) { 122 + RepeatableNotification.Like -> "liked" 123 + RepeatableNotification.Repost -> "reposted" 124 + } 125 + } this" 126 + 127 + remainingCount == 1 -> "$firstAuthorName and 1 other ${ 128 + when (data.kind) { 129 + RepeatableNotification.Like -> "liked" 130 + RepeatableNotification.Repost -> "reposted" 131 + } 132 + } this" 133 + 134 + else -> "$firstAuthorName ${ 135 + when (data.kind) { 136 + RepeatableNotification.Like -> "liked" 137 + RepeatableNotification.Repost -> "reposted" 138 + } 139 + } this" 140 + } 117 141 142 + Column( 143 + modifier = Modifier 144 + .weight(1f) 145 + .padding(start = 16.dp) 146 + ) { 147 + Text( 148 + modifier = Modifier.fillMaxWidth(), 149 + text = text, 150 + style = MaterialTheme.typography.bodyMedium, 151 + fontWeight = FontWeight.Bold, 152 + ) 153 + Text( 154 + text = HumanReadable.timeAgo(data.timestamp), 155 + color = MaterialTheme.colorScheme.onSurfaceVariant, 156 + style = MaterialTheme.typography.bodySmall, 157 + textAlign = TextAlign.End, 158 + modifier = Modifier.fillMaxWidth() 159 + ) 160 + Text( 161 + modifier = Modifier.fillMaxWidth(), 162 + text = data.post.text, 163 + color = MaterialTheme.colorScheme.secondary, 164 + style = MaterialTheme.typography.bodySmall, 165 + ) 166 + } 118 167 } 119 168 } 120 169 } 121 170 } 171 + 172 + private val HeartFilled: ImageVector 173 + get() { 174 + if (_HeartFilled != null) return _HeartFilled!! 175 + 176 + _HeartFilled = ImageVector.Builder( 177 + name = "HeartFilled", 178 + defaultWidth = 16.dp, 179 + defaultHeight = 16.dp, 180 + viewportWidth = 16f, 181 + viewportHeight = 16f 182 + ).apply { 183 + path( 184 + fill = SolidColor(Color.Black) 185 + ) { 186 + moveTo(14.88f, 4.78079f) 187 + curveTo(14.7993f, 4.46498f, 14.6748f, 4.16202f, 14.51f, 3.88077f) 188 + curveTo(14.3518f, 3.58819f, 14.1493f, 3.3217f, 13.91f, 3.09073f) 189 + curveTo(13.563f, 2.74486f, 13.152f, 2.46982f, 12.7f, 2.28079f) 190 + curveTo(11.7902f, 1.90738f, 10.7698f, 1.90738f, 9.85999f, 2.28079f) 191 + curveTo(9.43276f, 2.46163f, 9.04027f, 2.71541f, 8.70002f, 3.03079f) 192 + lineTo(8.65003f, 3.09073f) 193 + lineTo(8.00001f, 3.74075f) 194 + lineTo(7.34999f, 3.09073f) 195 + lineTo(7.3f, 3.03079f) 196 + curveTo(6.95975f, 2.71541f, 6.56726f, 2.46163f, 6.14002f, 2.28079f) 197 + curveTo(5.23018f, 1.90738f, 4.20984f, 1.90738f, 3.3f, 2.28079f) 198 + curveTo(2.84798f, 2.46982f, 2.43706f, 2.74486f, 2.09004f, 3.09073f) 199 + curveTo(1.85051f, 3.32402f, 1.64514f, 3.59002f, 1.48002f, 3.88077f) 200 + curveTo(1.32258f, 4.1644f, 1.20161f, 4.46682f, 1.12f, 4.78079f) 201 + curveTo(1.03522f, 5.10721f, 0.994861f, 5.44358f, 1.00001f, 5.78079f) 202 + curveTo(1.00053f, 6.09791f, 1.04084f, 6.41365f, 1.12f, 6.72073f) 203 + curveTo(1.20384f, 7.03078f, 1.32472f, 7.32961f, 1.48002f, 7.61075f) 204 + curveTo(1.64774f, 7.89975f, 1.85285f, 8.16542f, 2.09004f, 8.40079f) 205 + lineTo(8.00001f, 14.3108f) 206 + lineTo(13.91f, 8.40079f) 207 + curveTo(14.1471f, 8.16782f, 14.3492f, 7.90169f, 14.51f, 7.61075f) 208 + curveTo(14.6729f, 7.33211f, 14.7974f, 7.03272f, 14.88f, 6.72073f) 209 + curveTo(14.9592f, 6.41365f, 14.9995f, 6.09791f, 15f, 5.78079f) 210 + curveTo(15.0052f, 5.44358f, 14.9648f, 5.10721f, 14.88f, 4.78079f) 211 + close() 212 + } 213 + }.build() 214 + 215 + return _HeartFilled!! 216 + } 217 + 218 + private var _HeartFilled: ImageVector? = null 219 +
-8
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 497 497 ) 498 498 } 499 499 } 500 - } 501 - 502 - @Composable 503 - fun Divider() { 504 - HorizontalDivider( 505 - modifier = Modifier.padding(start = 16.dp, end = 16.dp), 506 - color = MaterialTheme.colorScheme.outlineVariant 507 - ) 508 500 }
+7 -2
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 260 260 .decodeAs() 261 261 262 262 } 263 - // is PostEmbedUnion.RecordWithMedia -> TODO() 263 + 264 + is PostEmbedUnion.RecordWithMedia -> { 265 + fetchRecord((rpp.embed as PostEmbedUnion.RecordWithMedia).value.record.record.uri).getOrThrow() 266 + .decodeAs() 267 + } 268 + 264 269 else -> rpp 265 270 } 266 271 ··· 270 275 p.createdAt.toStdlibInstant() 271 276 ) 272 277 273 - null // repeatable, will be processed later 278 + null 274 279 } 275 280 276 281 else -> {