A cheap attempt at a native Bluesky client for Android

*: refactor to cards, don't show reposted and reply to, don't show parent for reposts, better handling of top bar hide

+202 -145
+52 -56
app/src/main/java/industries/geesawra/monarch/LikeRowView.kt
··· 3 3 import androidx.compose.foundation.layout.Arrangement 4 4 import androidx.compose.foundation.layout.Column 5 5 import androidx.compose.foundation.layout.Row 6 + import androidx.compose.foundation.layout.fillMaxSize 6 7 import androidx.compose.foundation.layout.fillMaxWidth 7 8 import androidx.compose.foundation.layout.padding 8 9 import androidx.compose.foundation.layout.size ··· 36 37 val minSize = 55.dp 37 38 38 39 Surface( 39 - color = color, 40 + color = Color.Transparent, 40 41 modifier = modifier 41 - .padding(start = 16.dp, end = 16.dp) 42 - .fillMaxWidth() 42 + .padding(top = 8.dp, start = 16.dp, end = 16.dp) 43 + .fillMaxSize() 43 44 ) { 44 - Column { 45 - Row( 46 - verticalAlignment = Alignment.Top, 47 - horizontalArrangement = Arrangement.Start, 48 - modifier = Modifier.fillMaxWidth() 49 - ) { 50 - AsyncImage( 51 - model = ImageRequest.Builder(LocalContext.current) 52 - .data(likeData.authors.first().author.avatar?.uri) 53 - .crossfade(true) 54 - .build(), 55 - contentDescription = "Avatar", 56 - modifier = Modifier 57 - .size(minSize) 58 - .clip(CircleShape) 59 - ) 60 - 61 - val authors = likeData.authors 62 - val firstAuthorName = 63 - authors.first().author.displayName ?: authors.first().author.handle 64 - val remainingCount = authors.size - 1 65 - val text = when { 66 - remainingCount > 1 -> "$firstAuthorName and $remainingCount others liked this" 67 - remainingCount == 1 -> "$firstAuthorName and 1 other liked this" 68 - else -> "$firstAuthorName liked this" 69 - } 45 + Row( 46 + verticalAlignment = Alignment.Top, 47 + horizontalArrangement = Arrangement.Start, 48 + modifier = Modifier.fillMaxWidth() 49 + ) { 50 + AsyncImage( 51 + model = ImageRequest.Builder(LocalContext.current) 52 + .data(likeData.authors.first().author.avatar?.uri) 53 + .crossfade(true) 54 + .build(), 55 + contentDescription = "Avatar", 56 + modifier = Modifier 57 + .size(minSize) 58 + .clip(CircleShape) 59 + ) 70 60 71 - Column( 72 - modifier = Modifier 73 - .weight(1f) 74 - .padding(start = 16.dp) 75 - ) { 76 - Text( 77 - modifier = Modifier.fillMaxWidth(), 78 - text = text, 79 - style = MaterialTheme.typography.bodyMedium, 80 - fontWeight = FontWeight.Bold, 81 - ) 61 + val authors = likeData.authors 62 + val firstAuthorName = 63 + authors.first().author.displayName ?: authors.first().author.handle 64 + val remainingCount = authors.size - 1 65 + val text = when { 66 + remainingCount > 1 -> "$firstAuthorName and $remainingCount others liked this" 67 + remainingCount == 1 -> "$firstAuthorName and 1 other liked this" 68 + else -> "$firstAuthorName liked this" 69 + } 82 70 83 - Text( 84 - text = HumanReadable.timeAgo(likeData.timestamp), 85 - color = MaterialTheme.colorScheme.onSurfaceVariant, 86 - style = MaterialTheme.typography.bodySmall, 87 - textAlign = TextAlign.End, 88 - modifier = Modifier.fillMaxWidth() 89 - ) 71 + Column( 72 + modifier = Modifier 73 + .weight(1f) 74 + .padding(start = 16.dp) 75 + ) { 76 + Text( 77 + modifier = Modifier.fillMaxWidth(), 78 + text = text, 79 + style = MaterialTheme.typography.bodyMedium, 80 + fontWeight = FontWeight.Bold, 81 + ) 90 82 91 - Text( 92 - modifier = Modifier.fillMaxWidth(), 93 - text = likeData.post.text, 94 - color = MaterialTheme.colorScheme.secondary, 95 - style = MaterialTheme.typography.bodySmall, 96 - ) 83 + Text( 84 + text = HumanReadable.timeAgo(likeData.timestamp), 85 + color = MaterialTheme.colorScheme.onSurfaceVariant, 86 + style = MaterialTheme.typography.bodySmall, 87 + textAlign = TextAlign.End, 88 + modifier = Modifier.fillMaxWidth() 89 + ) 97 90 98 - } 91 + Text( 92 + modifier = Modifier.fillMaxWidth(), 93 + text = likeData.post.text, 94 + color = MaterialTheme.colorScheme.secondary, 95 + style = MaterialTheme.typography.bodySmall, 96 + ) 99 97 100 98 } 101 99 } 102 - 103 - 104 100 } 105 101 }
+43 -6
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 5 5 import android.widget.Toast 6 6 import androidx.annotation.StringRes 7 7 import androidx.compose.animation.AnimatedVisibility 8 + import androidx.compose.animation.core.animate 8 9 import androidx.compose.animation.slideInVertically 9 10 import androidx.compose.animation.slideOutVertically 10 11 import androidx.compose.foundation.layout.Arrangement ··· 21 22 import androidx.compose.foundation.rememberScrollState 22 23 import androidx.compose.foundation.shape.CircleShape 23 24 import androidx.compose.material.icons.Icons 24 - import androidx.compose.material.icons.filled.AirlineStops 25 25 import androidx.compose.material.icons.filled.ArrowUpward 26 26 import androidx.compose.material.icons.filled.Create 27 27 import androidx.compose.material.icons.filled.Home ··· 171 171 onError: (String) -> Unit, 172 172 ) { 173 173 var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.TIMELINE) } 174 - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( 174 + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( 175 175 rememberTopAppBarState() 176 176 ) 177 177 val timelineState = rememberLazyListState() ··· 329 329 .size(40.dp), 330 330 onClick = { 331 331 coroutineScope.launch { 332 - timelineState.animateScrollToItem(0) 332 + launch { 333 + if (timelineState.firstVisibleItemIndex > 8) { 334 + timelineState.scrollToItem(0) 335 + } else { 336 + timelineState.animateScrollToItem(0) 337 + } 338 + } 339 + 340 + launch { 341 + animate( 342 + initialValue = scrollBehavior.state.heightOffset, 343 + targetValue = 0f 344 + ) { value, /* velocity */ _ -> 345 + scrollBehavior.state.heightOffset = value 346 + } 347 + } 333 348 } 334 349 }, 335 350 shape = FloatingActionButtonDefaults.smallShape, ··· 347 362 } 348 363 349 364 TabBarDestinations.NOTIFICATIONS -> { 350 - if (notificationsState.canScrollBackward) { 365 + AnimatedVisibility( 366 + visible = notificationsState.canScrollBackward, 367 + enter = slideInVertically(), 368 + exit = slideOutVertically() 369 + ) { 351 370 FloatingActionButton( 371 + modifier = Modifier 372 + .size(40.dp), 352 373 onClick = { 353 374 coroutineScope.launch { 354 - notificationsState.animateScrollToItem(0) 375 + launch { 376 + if (notificationsState.firstVisibleItemIndex > 8) { 377 + notificationsState.scrollToItem(0) 378 + } else { 379 + notificationsState.animateScrollToItem(0) 380 + } 381 + } 382 + 383 + launch { 384 + animate( 385 + initialValue = scrollBehavior.state.heightOffset, 386 + targetValue = 0f 387 + ) { value, /* velocity */ _ -> 388 + scrollBehavior.state.heightOffset = value 389 + } 390 + } 355 391 } 356 392 }, 357 393 shape = FloatingActionButtonDefaults.smallShape, 358 394 ) { 359 - Icon(Icons.Default.AirlineStops, "Scroll to top") 395 + Icon(Icons.Default.ArrowUpward, "Scroll to top") 360 396 } 361 397 } 398 + 362 399 } 363 400 } 364 401 },
+11 -12
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 10 10 import androidx.compose.foundation.lazy.LazyColumn 11 11 import androidx.compose.foundation.lazy.LazyListState 12 12 import androidx.compose.material3.CircularProgressIndicator 13 - import androidx.compose.material3.HorizontalDivider 14 - import androidx.compose.material3.MaterialTheme 13 + import androidx.compose.material3.ElevatedCard 15 14 import androidx.compose.runtime.Composable 16 15 import androidx.compose.runtime.LaunchedEffect 17 16 import androidx.compose.runtime.derivedStateOf ··· 36 35 ) { 37 36 LazyColumn( 38 37 state = state, 39 - modifier = modifier.fillMaxSize(), 38 + modifier = modifier 39 + .fillMaxSize() 40 + .padding(horizontal = 16.dp), 40 41 userScrollEnabled = isScrollEnabled, 41 42 verticalArrangement = Arrangement.spacedBy(8.dp), 42 43 ) { 43 44 viewModel.uiState.notifications.list.forEach { notif -> 44 45 item(notif.createdAt()) { 45 - RenderNotification( 46 - viewModel = viewModel, 47 - notification = notif, 48 - onReplyTap = onReplyTap 49 - ) 50 - 51 - HorizontalDivider( 52 - color = MaterialTheme.colorScheme.outlineVariant 53 - ) 46 + ElevatedCard { 47 + RenderNotification( 48 + viewModel = viewModel, 49 + notification = notif, 50 + onReplyTap = onReplyTap 51 + ) 52 + } 54 53 } 55 54 } 56 55
+43 -35
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 12 12 import androidx.compose.foundation.lazy.rememberLazyListState 13 13 import androidx.compose.foundation.shape.RoundedCornerShape 14 14 import androidx.compose.material3.CircularProgressIndicator 15 - import androidx.compose.material3.HorizontalDivider 16 - import androidx.compose.material3.MaterialTheme 15 + import androidx.compose.material3.ElevatedCard 17 16 import androidx.compose.material3.VerticalDivider 18 17 import androidx.compose.runtime.Composable 19 18 import androidx.compose.runtime.LaunchedEffect ··· 24 23 import androidx.compose.ui.Modifier 25 24 import androidx.compose.ui.draw.clip 26 25 import androidx.compose.ui.unit.dp 26 + import app.bsky.feed.FeedViewPostReasonUnion 27 27 import industries.geesawra.monarch.datalayer.SkeetData 28 28 import industries.geesawra.monarch.datalayer.TimelineViewModel 29 29 ··· 38 38 LazyColumn( 39 39 state = state, 40 40 userScrollEnabled = isScrollEnabled, 41 - modifier = modifier.fillMaxSize(), 41 + modifier = modifier 42 + .fillMaxSize() 43 + .padding(horizontal = 16.dp), 42 44 verticalArrangement = Arrangement.spacedBy(8.dp), 43 45 ) { 44 46 viewModel.uiState.skeets.filter { 45 47 !it.replyToNotFollowing 46 48 }.forEach { skeet -> 47 49 item(key = skeet.key()) { 48 - val root = skeet.root() 49 - val (parent, parentsParent) = skeet.parent() 50 - root?.let { 51 - SkeetView( 52 - viewModel = viewModel, 53 - skeet = it, 54 - onReplyTap = onReplyTap, 55 - inThread = true 56 - ) 57 - } 50 + ElevatedCard { 51 + val isRepost = when (skeet.reason) { 52 + is FeedViewPostReasonUnion.ReasonRepost -> true 53 + else -> false 54 + } 58 55 59 - parent?.let { 60 - if ((parentsParent?.cid != root?.cid) && root?.cid != null) { 61 - ConditionalCard("See more") 56 + val root = skeet.root() 57 + val (parent, parentsParent) = skeet.parent() 62 58 63 - VerticalDivider( 64 - thickness = 4.dp, 65 - modifier = Modifier 66 - .height(50.dp) 67 - .padding(start = (16 + 25).dp) 68 - .clip(RoundedCornerShape(12.dp)) 69 - ) 70 - } 59 + if (!isRepost) { 60 + root?.let { 61 + SkeetView( 62 + viewModel = viewModel, 63 + skeet = it, 64 + onReplyTap = onReplyTap, 65 + inThread = true 66 + ) 67 + } 71 68 72 - SkeetView( 73 - viewModel = viewModel, 74 - skeet = it, 75 - onReplyTap = onReplyTap, 76 - inThread = true 77 - ) 78 - } 69 + parent?.let { 70 + if ((parentsParent?.cid != root?.cid) && root?.cid != null) { 71 + ConditionalCard("See more") 72 + 73 + VerticalDivider( 74 + thickness = 4.dp, 75 + modifier = Modifier 76 + .height(50.dp) 77 + .padding(start = (16 + 25).dp) 78 + .clip(RoundedCornerShape(12.dp)) 79 + ) 80 + } 79 81 82 + SkeetView( 83 + viewModel = viewModel, 84 + skeet = it, 85 + onReplyTap = onReplyTap, 86 + inThread = true 87 + ) 88 + } 89 + } 80 90 81 - SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap) 82 91 83 - HorizontalDivider( 84 - color = MaterialTheme.colorScheme.outlineVariant 85 - ) 92 + SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap) 93 + } 86 94 } 87 95 } 88 96
+53 -36
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 20 20 import androidx.compose.foundation.layout.size 21 21 import androidx.compose.foundation.layout.sizeIn 22 22 import androidx.compose.foundation.shape.CircleShape 23 - import androidx.compose.material3.Card 24 23 import androidx.compose.material3.HorizontalDivider 25 24 import androidx.compose.material3.MaterialTheme 26 25 import androidx.compose.material3.OutlinedCard ··· 88 87 val hasParent = parent != null 89 88 90 89 Surface( 91 - color = color, 92 - modifier = if (!inThread && !hasParent) { 93 - modifier.padding(start = 16.dp, end = 16.dp) 94 - } else { 95 - modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp) 96 - }.background(color) 90 + color = Color.Transparent, 91 + modifier = 92 + modifier 93 + .padding(top = 8.dp, start = 16.dp, end = 16.dp) 94 + .background(Color.Transparent) 97 95 ) { 98 96 Row( 99 97 verticalAlignment = Alignment.Top, ··· 106 104 .sizeIn(minHeight = minSize), 107 105 ) { 108 106 107 + SkeetReason(modifier = Modifier.padding(start = 4.dp), skeet = skeet) 108 + 109 109 Row( 110 110 modifier = Modifier 111 111 .fillMaxSize() 112 112 .padding(bottom = 8.dp), 113 - verticalAlignment = Alignment.Top 113 + verticalAlignment = Alignment.CenterVertically 114 114 ) { 115 115 AsyncImage( 116 116 model = ImageRequest.Builder(LocalContext.current) ··· 155 155 156 156 Text( 157 157 text = skeet.content, 158 - color = MaterialTheme.colorScheme.onSurface, 158 + color = MaterialTheme.colorScheme.onSurfaceVariant, 159 159 style = MaterialTheme.typography.bodyLarge, 160 160 ) 161 161 ··· 226 226 227 227 @Composable 228 228 private fun ImageView(img: List<ImagesViewImage>) { 229 - Card( 229 + OutlinedCard( 230 230 modifier = Modifier 231 231 .fillMaxWidth() 232 232 .padding(top = 8.dp, bottom = 8.dp), ··· 244 244 245 245 @Composable 246 246 fun VideoView(uri: Uri) { 247 - Card( 247 + OutlinedCard( 248 248 modifier = Modifier 249 249 .heightIn(max = 500.dp) 250 250 .fillMaxWidth() ··· 395 395 } 396 396 } 397 397 398 - @OptIn(ExperimentalLayoutApi::class) 399 398 @Composable 400 - private fun SkeetHeader(skeet: SkeetData, modifier: Modifier = Modifier) { 401 - val authorName = skeet.authorName ?: (skeet.authorHandle?.handle ?: "") 402 - 399 + private fun SkeetReason(modifier: Modifier = Modifier, skeet: SkeetData) { 403 400 Column(modifier = modifier) { 401 + var isRepost = false 404 402 skeet.reason?.let { 405 403 it 406 404 when (it) { ··· 414 412 .padding(bottom = 4.dp), 415 413 fontWeight = FontWeight.Bold 416 414 ) 415 + isRepost = true 417 416 } 418 417 419 418 else -> {} 420 419 } 421 420 } 422 421 422 + if (!isRepost) { 423 + skeet.reply?.let { 424 + it 425 + val parent = it.parent 426 + when (parent) { 427 + is ReplyRefParentUnion.PostView -> { 428 + Text( 429 + text = "In reply to ${parent.value.author.displayName ?: parent.value.author.handle.toString()}", 430 + color = MaterialTheme.colorScheme.onSurfaceVariant, 431 + style = MaterialTheme.typography.labelMedium, 432 + modifier = Modifier 433 + .fillMaxSize() 434 + .padding(bottom = 4.dp), 435 + fontWeight = FontWeight.Bold 436 + ) 437 + } 438 + 439 + else -> {} 440 + } 441 + } 442 + } 443 + } 444 + } 445 + 446 + @OptIn(ExperimentalLayoutApi::class) 447 + @Composable 448 + private fun SkeetHeader(modifier: Modifier = Modifier, skeet: SkeetData) { 449 + val authorName = skeet.authorName ?: (skeet.authorHandle?.handle ?: "") 450 + 451 + Column(modifier = modifier) { 423 452 Text( 424 453 text = authorName, 425 454 color = MaterialTheme.colorScheme.onSurface, ··· 447 476 return@forEach 448 477 } 449 478 450 - Card( 479 + OutlinedCard( 451 480 modifier = Modifier.padding(end = 4.dp, bottom = 4.dp), 452 481 shape = CircleShape 453 482 ) { ··· 460 489 } 461 490 } 462 491 463 - 464 - skeet.reply?.let { 465 - it 466 - val parent = it.parent 467 - when (parent) { 468 - is ReplyRefParentUnion.PostView -> { 469 - Text( 470 - text = "In reply to ${parent.value.author.displayName ?: parent.value.author.handle.toString()}", 471 - color = MaterialTheme.colorScheme.onSurfaceVariant, 472 - style = MaterialTheme.typography.labelSmall, 473 - modifier = Modifier 474 - .fillMaxSize() 475 - .padding(top = 4.dp), 476 - ) 477 - } 478 - 479 - else -> {} 480 - } 481 - } 482 - 483 492 skeet.createdAt?.let { 484 493 Text( 485 494 text = HumanReadable.timeAgo(it), ··· 491 500 } 492 501 } 493 502 } 503 + 504 + @Composable 505 + fun Divider() { 506 + HorizontalDivider( 507 + modifier = Modifier.padding(start = 16.dp, end = 16.dp), 508 + color = MaterialTheme.colorScheme.outlineVariant 509 + ) 510 + }