A cheap attempt at a native Bluesky client for Android
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: better activity indicator when getting new skeets

geesawra 402e317a ae84ef66

+155 -172
-21
app/src/main/java/industries/geesawra/jerryno/MainActivity.kt
··· 10 10 import androidx.compose.animation.scaleOut 11 11 import androidx.compose.foundation.layout.Box 12 12 import androidx.compose.foundation.layout.fillMaxSize 13 - import androidx.compose.foundation.layout.padding 14 13 import androidx.compose.foundation.rememberScrollState 15 14 import androidx.compose.foundation.verticalScroll 16 15 import androidx.compose.material3.ExperimentalMaterial3Api 17 16 import androidx.compose.material3.MaterialTheme 18 17 import androidx.compose.material3.Surface 19 - import androidx.compose.material3.Text 20 18 import androidx.compose.runtime.Composable 21 19 import androidx.compose.runtime.DisposableEffect 22 20 import androidx.compose.runtime.LaunchedEffect ··· 26 24 import androidx.compose.ui.Modifier 27 25 import androidx.compose.ui.graphics.TransformOrigin 28 26 import androidx.compose.ui.platform.LocalContext 29 - import androidx.compose.ui.unit.dp 30 27 import androidx.compose.ui.viewinterop.AndroidView 31 28 import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 32 29 import androidx.media3.common.MediaItem ··· 134 131 navController.navigate(TimelineScreen.Login.name) 135 132 } 136 133 ) 137 - } 138 - composable(route = TimelineScreen.Compose.name) { 139 - Surface( 140 - modifier = Modifier.fillMaxSize(), 141 - color = MaterialTheme.colorScheme.background // Set compose screen background 142 - ) { 143 - Box( 144 - modifier = Modifier.fillMaxSize(), 145 - contentAlignment = Alignment.Center, 146 - ) { 147 - Text( 148 - "Compose", 149 - modifier = Modifier 150 - .fillMaxSize() 151 - .padding(10.dp) 152 - ) 153 - } 154 - } 155 134 } 156 135 composable(route = TimelineScreen.Login.name) { 157 136 Surface(
+38 -60
app/src/main/java/industries/geesawra/jerryno/ShowSkeets.kt
··· 10 10 import androidx.compose.foundation.lazy.LazyListState 11 11 import androidx.compose.foundation.lazy.rememberLazyListState 12 12 import androidx.compose.material3.CircularProgressIndicator 13 - import androidx.compose.material3.pulltorefresh.PullToRefreshBox 14 13 import androidx.compose.runtime.Composable 15 14 import androidx.compose.runtime.LaunchedEffect 16 15 import androidx.compose.runtime.derivedStateOf 17 16 import androidx.compose.runtime.getValue 18 - import androidx.compose.runtime.mutableStateOf 19 17 import androidx.compose.runtime.remember 20 18 import androidx.compose.ui.Alignment 21 19 import androidx.compose.ui.Modifier ··· 27 25 modifier: Modifier = Modifier, 28 26 viewModel: TimelineViewModel, 29 27 state: LazyListState = rememberLazyListState(), 28 + doneFirstRefresh: () -> Unit = {} 30 29 ) { 31 30 LaunchedEffect(key1 = viewModel.uiState.skeets.isEmpty()) { 32 31 if (viewModel.uiState.skeets.isEmpty()) { 33 - viewModel.fetchTimeline() 32 + viewModel.fetchTimeline { 33 + doneFirstRefresh() 34 + } 34 35 } 35 36 } 36 37 37 - val isRefreshing = remember { mutableStateOf(false) } 38 - 39 - PullToRefreshBox( 40 - isRefreshing = isRefreshing.value, 41 - onRefresh = { 42 - viewModel.reset() 43 - viewModel.fetchTimeline() 44 - }, 38 + LazyColumn( 39 + state = state, 40 + modifier = modifier.fillMaxSize(), 41 + verticalArrangement = Arrangement.spacedBy(8.dp), 45 42 ) { 43 + viewModel.uiState.skeets.distinctBy { it.post.cid }.forEach { skeet -> 44 + item(key = skeet.post.cid.cid) { 45 + SkeetRowView(viewModel, skeet) 46 + } 47 + } 46 48 47 - LazyColumn( 48 - state = state, 49 - modifier = modifier.fillMaxSize(), 50 - verticalArrangement = Arrangement.spacedBy(8.dp), 51 - ) { 52 - 53 - if (viewModel.uiState.skeets.isEmpty()) { 54 - item { 49 + if (viewModel.uiState.isFetchingMoreTimeline && viewModel.uiState.skeets.isNotEmpty()) { 50 + item { 51 + Box( 52 + modifier = Modifier 53 + .fillMaxWidth() 54 + .padding(16.dp), 55 + contentAlignment = Alignment.Center 56 + ) { 55 57 Box( 56 58 contentAlignment = Alignment.Center, 57 59 modifier = Modifier.fillMaxSize() 58 60 ) { 59 - CircularProgressIndicator() 60 - } 61 - } 62 - } else { 63 - viewModel.uiState.skeets.distinctBy { it.post.cid }.forEach { skeet -> 64 - item(key = skeet.post.cid.cid) { 65 - SkeetRowView(viewModel, skeet) 66 - } 67 - } 68 - 69 - if (viewModel.uiState.isFetchingMoreTimeline) { 70 - item { 71 - Box( 61 + CircularProgressIndicator( 72 62 modifier = Modifier 73 - .fillMaxWidth() 74 - .padding(16.dp), 75 - contentAlignment = Alignment.Center 76 - ) { 77 - Box( 78 - contentAlignment = Alignment.Center, 79 - modifier = Modifier.fillMaxSize() 80 - ) { 81 - CircularProgressIndicator( 82 - modifier = Modifier 83 - .width(64.dp), 84 - ) 85 - } 86 - } 63 + .width(64.dp), 64 + ) 87 65 } 88 66 } 89 67 } 90 68 } 69 + } 91 70 92 - val endOfListReached by remember { 93 - derivedStateOf { 94 - val layoutInfo = state.layoutInfo 95 - val visibleItemsInfo = layoutInfo.visibleItemsInfo 96 - if (layoutInfo.totalItemsCount == 0) { 97 - false 98 - } else { 99 - val lastVisibleItem = visibleItemsInfo.lastOrNull() 100 - lastVisibleItem != null && lastVisibleItem.index == layoutInfo.totalItemsCount - 1 101 - } 71 + val endOfListReached by remember { 72 + derivedStateOf { 73 + val layoutInfo = state.layoutInfo 74 + val visibleItemsInfo = layoutInfo.visibleItemsInfo 75 + if (layoutInfo.totalItemsCount == 0) { 76 + false 77 + } else { 78 + val lastVisibleItem = visibleItemsInfo.lastOrNull() 79 + lastVisibleItem != null && lastVisibleItem.index == layoutInfo.totalItemsCount - 1 102 80 } 103 81 } 82 + } 104 83 105 - LaunchedEffect(endOfListReached) { 106 - if (endOfListReached && viewModel.uiState.skeets.isNotEmpty()) { 107 - viewModel.fetchTimeline() 108 - } 84 + LaunchedEffect(endOfListReached) { 85 + if (endOfListReached && viewModel.uiState.skeets.isNotEmpty()) { 86 + viewModel.fetchTimeline() 109 87 } 110 88 } 111 89 }
+106 -88
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 37 37 import androidx.compose.material3.ButtonDefaults 38 38 import androidx.compose.material3.Card 39 39 import androidx.compose.material3.CircularProgressIndicator 40 - import androidx.compose.material3.DrawerState 41 40 import androidx.compose.material3.DrawerValue 42 41 import androidx.compose.material3.ExperimentalMaterial3Api 43 42 import androidx.compose.material3.FloatingActionButton 44 43 import androidx.compose.material3.Icon 45 44 import androidx.compose.material3.IconButton 46 - import androidx.compose.material3.LargeTopAppBar 47 45 import androidx.compose.material3.MaterialTheme 48 46 import androidx.compose.material3.ModalDrawerSheet 49 47 import androidx.compose.material3.ModalNavigationDrawer ··· 55 53 import androidx.compose.material3.Scaffold 56 54 import androidx.compose.material3.Text 57 55 import androidx.compose.material3.TextButton 56 + import androidx.compose.material3.TopAppBar 58 57 import androidx.compose.material3.TopAppBarColors 59 58 import androidx.compose.material3.TopAppBarDefaults 59 + import androidx.compose.material3.pulltorefresh.PullToRefreshBox 60 60 import androidx.compose.material3.rememberBottomSheetScaffoldState 61 61 import androidx.compose.material3.rememberDrawerState 62 62 import androidx.compose.material3.rememberModalBottomSheetState ··· 365 365 .show() 366 366 } 367 367 } 368 + val isRefreshing = remember { mutableStateOf(true) } 368 369 369 - ModalNavigationDrawer( 370 - drawerState = drawerState, 371 - modifier = modifier, 372 - drawerContent = { 373 - FeedsDrawer(timelineViewModel, coroutineScope, drawerState) 374 - } 370 + PullToRefreshBox( 371 + isRefreshing = isRefreshing.value, 372 + onRefresh = { 373 + isRefreshing.value = true 374 + timelineViewModel.reset() 375 + timelineViewModel.fetchTimeline { 376 + isRefreshing.value = false 377 + } 378 + }, 375 379 ) { 376 - Scaffold( 377 - containerColor = MaterialTheme.colorScheme.background, 378 - modifier = Modifier 379 - .fillMaxSize() 380 - .nestedScroll(scrollBehavior.nestedScrollConnection), 381 - topBar = { 382 - LargeTopAppBar( 383 - colors = TopAppBarColors( 384 - containerColor = MaterialTheme.colorScheme.background, 385 - scrolledContainerColor = MaterialTheme.colorScheme.background, 386 - navigationIconContentColor = MaterialTheme.colorScheme.onBackground, // Ensuring correct contrast 387 - titleContentColor = MaterialTheme.colorScheme.onBackground, 388 - actionIconContentColor = MaterialTheme.colorScheme.onBackground, 389 - subtitleContentColor = MaterialTheme.colorScheme.onBackground 390 - ), 391 - title = { 392 - Row( 393 - horizontalArrangement = Arrangement.spacedBy(8.dp), 394 - verticalAlignment = Alignment.CenterVertically 395 - ) { 396 - if (timelineViewModel.uiState.feedAvatar != null) { 397 - AsyncImage( 398 - model = timelineViewModel.uiState.feedAvatar, 399 - modifier = Modifier 400 - .size(42.dp) 401 - .shadow(10.dp, CircleShape) 402 - .clip(CircleShape), 403 - contentDescription = "Feed avatar", 404 - ) 405 - } 406 - 407 - Text(text = timelineViewModel.uiState.feedName) 380 + ModalNavigationDrawer( 381 + drawerState = drawerState, 382 + modifier = modifier, 383 + drawerContent = { 384 + FeedsDrawer( 385 + { uri: String, displayName: String, avatar: String? -> 386 + isRefreshing.value = true 387 + timelineViewModel.selectFeed(uri, displayName, avatar) { 388 + isRefreshing.value = false 408 389 } 409 - }, 410 - scrollBehavior = scrollBehavior, 411 - modifier = Modifier.clickable { 412 390 coroutineScope.launch { 413 - listState.animateScrollToItem(0) 391 + drawerState.close() 414 392 } 415 393 }, 416 - navigationIcon = { 417 - IconButton(onClick = { 394 + 395 + timelineViewModel 396 + ) 397 + } 398 + ) { 399 + Scaffold( 400 + containerColor = MaterialTheme.colorScheme.background, 401 + modifier = Modifier 402 + .fillMaxSize() 403 + .nestedScroll(scrollBehavior.nestedScrollConnection), 404 + topBar = { 405 + TopAppBar( 406 + colors = TopAppBarColors( 407 + containerColor = MaterialTheme.colorScheme.background, 408 + scrolledContainerColor = MaterialTheme.colorScheme.background, 409 + navigationIconContentColor = MaterialTheme.colorScheme.onBackground, // Ensuring correct contrast 410 + titleContentColor = MaterialTheme.colorScheme.onBackground, 411 + actionIconContentColor = MaterialTheme.colorScheme.onBackground, 412 + subtitleContentColor = MaterialTheme.colorScheme.onBackground 413 + ), 414 + title = { 415 + Row( 416 + horizontalArrangement = Arrangement.spacedBy(8.dp), 417 + verticalAlignment = Alignment.CenterVertically 418 + ) { 419 + if (timelineViewModel.uiState.feedAvatar != null) { 420 + AsyncImage( 421 + model = timelineViewModel.uiState.feedAvatar, 422 + modifier = Modifier 423 + .size(42.dp) 424 + .shadow(10.dp, CircleShape) 425 + .clip(CircleShape), 426 + contentDescription = "Feed avatar", 427 + ) 428 + } 429 + 430 + Text(text = timelineViewModel.uiState.feedName) 431 + } 432 + }, 433 + scrollBehavior = scrollBehavior, 434 + modifier = Modifier.clickable { 418 435 coroutineScope.launch { 419 - drawerState.open() 436 + listState.animateScrollToItem(0) 420 437 } 421 - }) { 422 - Icon(Icons.Default.Tag, "Feeds") 438 + }, 439 + navigationIcon = { 440 + IconButton(onClick = { 441 + coroutineScope.launch { 442 + drawerState.open() 443 + } 444 + }) { 445 + Icon(Icons.Default.Tag, "Feeds") 446 + } 447 + }, 448 + ) 449 + }, 450 + floatingActionButton = { 451 + FloatingActionButton( 452 + onClick = fobOnClick 453 + ) { 454 + Icon(Icons.Filled.Create, "Post") 455 + } 456 + }, 457 + bottomBar = { 458 + NavigationBar { 459 + TabBarDestinations.entries.forEach { 460 + NavigationBarItem( 461 + icon = { 462 + Icon( 463 + it.icon, 464 + contentDescription = stringResource(it.contentDescription) 465 + ) 466 + }, 467 + label = { Text(stringResource(it.label)) }, 468 + selected = it == currentDestination, 469 + onClick = { currentDestination = it } 470 + ) 423 471 } 424 - }, 425 - ) 426 - }, 427 - floatingActionButton = { 428 - FloatingActionButton( 429 - onClick = fobOnClick 472 + } 473 + } 474 + ) { values -> 475 + ShowSkeets( 476 + viewModel = timelineViewModel, 477 + state = listState, 478 + modifier = Modifier.padding(values) 430 479 ) { 431 - Icon(Icons.Filled.Create, "Post") 432 - } 433 - }, 434 - bottomBar = { 435 - NavigationBar { 436 - TabBarDestinations.entries.forEach { 437 - NavigationBarItem( 438 - icon = { 439 - Icon( 440 - it.icon, 441 - contentDescription = stringResource(it.contentDescription) 442 - ) 443 - }, 444 - label = { Text(stringResource(it.label)) }, 445 - selected = it == currentDestination, 446 - onClick = { currentDestination = it } 447 - ) 448 - } 480 + isRefreshing.value = false 449 481 } 450 482 } 451 - ) { values -> 452 - ShowSkeets( 453 - viewModel = timelineViewModel, 454 - state = listState, 455 - modifier = Modifier.padding(values) 456 - ) 457 483 } 458 484 } 459 485 } 460 486 461 487 @Composable 462 488 fun FeedsDrawer( 489 + selectFeed: (uri: String, displayName: String, avatar: String?) -> Unit, 463 490 timelineViewModel: TimelineViewModel, 464 - coroutineScope: CoroutineScope, 465 - drawerState: DrawerState 466 491 ) { 467 - val selectFeed = { uri: String, displayName: String, avatar: String? -> 468 - timelineViewModel.selectFeed(uri, displayName, avatar) 469 - coroutineScope.launch { 470 - drawerState.close() 471 - } 472 - } 473 - 474 492 ModalDrawerSheet { 475 493 Text( 476 494 "Feeds",
+11 -3
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 17 17 import sh.christian.ozone.api.AtUri 18 18 import sh.christian.ozone.api.Cid 19 19 import sh.christian.ozone.api.RKey 20 + import kotlin.coroutines.cancellation.CancellationException 20 21 21 22 22 23 data class TimelineUiState( ··· 63 64 } 64 65 65 66 66 - fun fetchTimeline() { 67 + fun fetchTimeline(then: () -> Unit = {}) { 67 68 uiState = uiState.copy(isFetchingMoreTimeline = true) 68 69 fetchJob?.cancel() 69 70 ··· 80 81 cursor = it.cursor, 81 82 isFetchingMoreTimeline = false 82 83 ) 84 + then() 83 85 }.onFailure { 86 + if (it is CancellationException) { 87 + return@onFailure 88 + } 89 + 90 + then() 91 + 84 92 uiState = uiState.copy( 85 93 isFetchingMoreTimeline = false, 86 94 error = "Failed to fetch timeline: ${it.message}" ··· 116 124 } 117 125 } 118 126 119 - fun selectFeed(uri: String, displayName: String, avatar: String?) { 127 + fun selectFeed(uri: String, displayName: String, avatar: String?, then: () -> Unit = {}) { 120 128 uiState = uiState.copy( 121 129 selectedFeed = uri, 122 130 feedName = displayName, 123 131 feedAvatar = avatar, 124 132 ) 125 133 reset() 126 - fetchTimeline() 134 + fetchTimeline { then() } 127 135 } 128 136 129 137 fun like(uri: AtUri, cid: Cid, then: () -> Unit) {