A cheap attempt at a native Bluesky client for Android

Bluesky: add notifications tab

This commit introduces a new "Notifications" tab to the main interface.

Changes include:
- Added a `NotificationsView` to display notifications.
- Implemented fetching logic for notifications in `TimelineViewModel`, including pagination.
- Updated `MainView` to handle state and UI for both the Timeline and Notifications tabs, such as separate scroll states and conditional UI elements (FAB, top app bar content).
- Added the `listNotifications` endpoint wrapper to the `Bluesky` data layer.

+226 -41
+69 -30
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 150 150 val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( 151 151 rememberTopAppBarState() 152 152 ) 153 - val listState = rememberLazyListState() 153 + val timelineState = rememberLazyListState() 154 + val notificationsState = rememberLazyListState() 154 155 val drawerState = rememberDrawerState( 155 156 initialValue = DrawerValue.Closed 156 157 ) ··· 181 182 isRefreshing = isRefreshing.value, 182 183 onRefresh = { 183 184 isRefreshing.value = true 184 - timelineViewModel.reset() 185 - timelineViewModel.fetchTimeline { isRefreshing.value = false } 185 + when (currentDestination) { 186 + TabBarDestinations.TIMELINE -> { 187 + timelineViewModel.reset() 188 + timelineViewModel.fetchTimeline { isRefreshing.value = false } 189 + } 190 + 191 + TabBarDestinations.NOTIFICATIONS -> { 192 + timelineViewModel.reset() 193 + timelineViewModel.fetchNotifications { isRefreshing.value = false } 194 + } 195 + } 186 196 }, 187 197 ) { 188 198 ModalNavigationDrawer( ··· 201 211 drawerState.close() 202 212 } 203 213 }, 204 - 205 214 timelineViewModel 206 215 ) 207 216 } ··· 222 231 subtitleContentColor = MaterialTheme.colorScheme.onBackground 223 232 ), 224 233 title = { 225 - Row( 226 - horizontalArrangement = Arrangement.spacedBy(8.dp), 227 - verticalAlignment = Alignment.CenterVertically 228 - ) { 229 - if (timelineViewModel.uiState.feedAvatar != null) { 230 - AsyncImage( 231 - model = timelineViewModel.uiState.feedAvatar, 232 - modifier = Modifier 233 - .size(42.dp) 234 - .shadow(10.dp, CircleShape) 235 - .clip(CircleShape), 236 - contentDescription = "Feed avatar", 237 - ) 234 + when (currentDestination) { 235 + TabBarDestinations.TIMELINE -> Row( 236 + horizontalArrangement = Arrangement.spacedBy(8.dp), 237 + verticalAlignment = Alignment.CenterVertically 238 + ) { 239 + if (timelineViewModel.uiState.feedAvatar != null) { 240 + AsyncImage( 241 + model = timelineViewModel.uiState.feedAvatar, 242 + modifier = Modifier 243 + .size(42.dp) 244 + .shadow(10.dp, CircleShape) 245 + .clip(CircleShape), 246 + contentDescription = "Feed avatar", 247 + ) 248 + } 249 + 250 + Text(text = timelineViewModel.uiState.feedName) 238 251 } 239 252 240 - Text(text = timelineViewModel.uiState.feedName) 253 + TabBarDestinations.NOTIFICATIONS -> Row( 254 + horizontalArrangement = Arrangement.spacedBy(8.dp), 255 + verticalAlignment = Alignment.CenterVertically 256 + ) { 257 + Text(text = "Notifications") 258 + } 241 259 } 242 260 }, 243 261 scrollBehavior = scrollBehavior, 244 262 modifier = Modifier.clickable { 245 263 coroutineScope.launch { 246 - listState.animateScrollToItem(0) 264 + when (currentDestination) { 265 + TabBarDestinations.TIMELINE -> timelineState.animateScrollToItem( 266 + 0 267 + ) 268 + 269 + TabBarDestinations.NOTIFICATIONS -> notificationsState.animateScrollToItem( 270 + 0 271 + ) 272 + 273 + } 247 274 } 248 275 }, 249 276 navigationIcon = { 250 - IconButton(onClick = { 251 - coroutineScope.launch { 252 - drawerState.open() 277 + when (currentDestination) { 278 + TabBarDestinations.TIMELINE -> IconButton(onClick = { 279 + coroutineScope.launch { 280 + drawerState.open() 281 + } 282 + }) { 283 + Icon(Icons.Default.Tag, "Feeds") 253 284 } 254 - }) { 255 - Icon(Icons.Default.Tag, "Feeds") 285 + 286 + TabBarDestinations.NOTIFICATIONS -> {} 256 287 } 257 288 }, 258 289 ) 259 290 }, 260 291 floatingActionButton = { 261 - FloatingActionButton( 262 - onClick = fobOnClick 263 - ) { 264 - Icon(Icons.Filled.Create, "Post") 292 + when (currentDestination) { 293 + TabBarDestinations.TIMELINE -> FloatingActionButton( 294 + onClick = fobOnClick 295 + ) { 296 + Icon(Icons.Filled.Create, "Post") 297 + } 298 + 299 + TabBarDestinations.NOTIFICATIONS -> {} 265 300 } 266 301 }, 267 302 bottomBar = { ··· 285 320 when (currentDestination) { 286 321 TabBarDestinations.TIMELINE -> ShowSkeets( 287 322 viewModel = timelineViewModel, 288 - state = listState, 323 + state = timelineState, 289 324 modifier = Modifier.padding(values), 290 325 onReplyTap = onReplyTap 291 326 ) { isRefreshing.value = false } 292 327 293 328 TabBarDestinations.NOTIFICATIONS -> NotificationsView( 329 + viewModel = timelineViewModel, 330 + state = notificationsState, 294 331 modifier = Modifier.padding(values) 295 - ) 332 + ) { 333 + isRefreshing.value = false 334 + } 296 335 } 297 336 } 298 337 }
+82 -3
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 - import androidx.compose.foundation.layout.Column 3 + import androidx.compose.foundation.layout.Arrangement 4 + import androidx.compose.foundation.layout.Box 5 + import androidx.compose.foundation.layout.fillMaxSize 6 + import androidx.compose.foundation.layout.fillMaxWidth 7 + import androidx.compose.foundation.layout.padding 8 + import androidx.compose.foundation.layout.width 9 + import androidx.compose.foundation.lazy.LazyColumn 10 + import androidx.compose.foundation.lazy.LazyListState 11 + import androidx.compose.material3.CircularProgressIndicator 12 + import androidx.compose.material3.Text 4 13 import androidx.compose.runtime.Composable 14 + import androidx.compose.runtime.LaunchedEffect 15 + import androidx.compose.runtime.derivedStateOf 16 + import androidx.compose.runtime.getValue 17 + import androidx.compose.runtime.remember 18 + import androidx.compose.ui.Alignment 5 19 import androidx.compose.ui.Modifier 20 + import androidx.compose.ui.unit.dp 21 + import industries.geesawra.monarch.datalayer.TimelineViewModel 6 22 7 23 @Composable 8 24 fun NotificationsView( 9 - modifier: Modifier = Modifier 25 + viewModel: TimelineViewModel, 26 + state: LazyListState, 27 + modifier: Modifier = Modifier, 28 + doneRefresh: () -> Unit = {}, 10 29 ) { 11 - Column(modifier = modifier) {} 30 + LaunchedEffect(key1 = viewModel.uiState.notifications.isEmpty()) { 31 + if (viewModel.uiState.notifications.isEmpty()) { 32 + viewModel.fetchNotifications { 33 + doneRefresh() 34 + } 35 + } 36 + } 37 + 38 + LazyColumn( 39 + state = state, 40 + modifier = modifier.fillMaxSize(), 41 + verticalArrangement = Arrangement.spacedBy(8.dp), 42 + ) { 43 + viewModel.uiState.notifications.forEach { notif -> 44 + item() {// TODO: group notification by (cid, uri) and kind 45 + Text( 46 + notif.record.toString() 47 + ) 48 + } 49 + } 50 + 51 + if (viewModel.uiState.isFetchingMoreNotifications && viewModel.uiState.notifications.isNotEmpty()) { 52 + item { 53 + Box( 54 + modifier = Modifier 55 + .fillMaxWidth() 56 + .padding(16.dp), 57 + contentAlignment = Alignment.Center 58 + ) { 59 + Box( 60 + contentAlignment = Alignment.Center, 61 + modifier = Modifier.fillMaxSize() 62 + ) { 63 + CircularProgressIndicator( 64 + modifier = Modifier 65 + .width(64.dp), 66 + ) 67 + } 68 + } 69 + } 70 + } 71 + } 72 + 73 + val endOfListReached by remember { 74 + derivedStateOf { 75 + val layoutInfo = state.layoutInfo 76 + val visibleItemsInfo = layoutInfo.visibleItemsInfo 77 + if (layoutInfo.totalItemsCount == 0) { 78 + false 79 + } else { 80 + val lastVisibleItem = visibleItemsInfo.lastOrNull() 81 + lastVisibleItem != null && lastVisibleItem.index == layoutInfo.totalItemsCount - 1 82 + } 83 + } 84 + } 85 + 86 + LaunchedEffect(endOfListReached) { 87 + if (endOfListReached && viewModel.uiState.notifications.isNotEmpty()) { 88 + viewModel.fetchNotifications() 89 + } 90 + } 12 91 }
+24 -1
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 23 23 import app.bsky.feed.PostEmbedUnion 24 24 import app.bsky.feed.PostReplyRef 25 25 import app.bsky.feed.Repost 26 + import app.bsky.notification.ListNotificationsQueryParams 27 + import app.bsky.notification.ListNotificationsResponse 26 28 import app.bsky.video.GetJobStatusQueryParams 27 29 import app.bsky.video.GetJobStatusResponse 28 30 import app.bsky.video.JobStatus ··· 61 63 import kotlinx.coroutines.flow.map 62 64 import kotlinx.coroutines.sync.Mutex 63 65 import kotlinx.datetime.Clock 66 + import kotlinx.datetime.Instant 64 67 import kotlinx.serialization.Serializable 65 68 import kotlinx.serialization.json.Json 66 69 import sh.christian.ozone.BlueskyJson ··· 436 439 replyRef: PostReplyRef? = null, 437 440 quotePostRef: StrongRef? = null 438 441 ): Result<Unit> { 439 - // TODO: videos need to be uploaded through a different API. 440 442 return runCatching { 441 443 create().onFailure { 442 444 return Result.failure(LoginException(it.message)) ··· 681 683 ).requireResponse() 682 684 683 685 return Result.success(resp.feeds) 686 + } 687 + } 688 + 689 + suspend fun notifications( 690 + cursor: String? = null, 691 + lastCalled: Instant? = null 692 + ): Result<ListNotificationsResponse> { 693 + return runCatching { 694 + create().onFailure { 695 + return Result.failure(LoginException(it.message)) 696 + } 697 + 698 + val ret = client!!.listNotifications( 699 + ListNotificationsQueryParams( 700 + cursor = cursor, 701 + ) 702 + ) 703 + return when (ret) { 704 + is AtpResponse.Failure<*> -> Result.failure(Exception("Failed to fetch notifications: ${ret.error}")) 705 + is AtpResponse.Success<ListNotificationsResponse> -> Result.success(ret.response) 706 + } 684 707 } 685 708 } 686 709
+51 -7
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 8 8 import androidx.lifecycle.viewModelScope 9 9 import app.bsky.feed.GeneratorView 10 10 import app.bsky.feed.PostReplyRef 11 + import app.bsky.notification.ListNotificationsNotification 11 12 import com.atproto.repo.StrongRef 12 13 import dagger.assisted.Assisted 13 14 import dagger.assisted.AssistedFactory ··· 15 16 import dagger.hilt.android.lifecycle.HiltViewModel 16 17 import kotlinx.coroutines.Job 17 18 import kotlinx.coroutines.launch 19 + import kotlinx.datetime.Clock 20 + import kotlinx.datetime.Instant 18 21 import sh.christian.ozone.api.AtUri 19 22 import sh.christian.ozone.api.Cid 20 23 import sh.christian.ozone.api.RKey ··· 27 30 val feedAvatar: String? = null, 28 31 val feeds: List<GeneratorView> = listOf(), 29 32 val skeets: List<SkeetData> = listOf(), 33 + val notifications: List<ListNotificationsNotification> = listOf(), 30 34 val isFetchingMoreTimeline: Boolean = false, 31 - val cursor: String? = null, 35 + val isFetchingMoreNotifications: Boolean = false, 32 36 val authenticated: Boolean = false, 33 37 val sessionChecked: Boolean = false, 34 38 39 + val timelineCursor: String? = null, 40 + val notificationsCursor: String? = null, 41 + val lastDownloadedNotifs: Instant? = null, 42 + 35 43 val cidInteractedWith: Map<Cid, RKey> = mapOf(), 36 44 37 45 val loginError: String? = null, ··· 51 59 var uiState by mutableStateOf(TimelineUiState()) 52 60 private set 53 61 54 - private var fetchJob: Job? = null 62 + private var timelineFetchJob: Job? = null 63 + private var notificationsFetchJob: Job? = null 55 64 56 65 fun loadSession() { 57 66 viewModelScope.launch { ··· 68 77 fun fetchTimeline(then: () -> Unit = {}) { 69 78 uiState = uiState.copy(isFetchingMoreTimeline = true) 70 79 runCatching { 71 - fetchJob?.cancel() 80 + timelineFetchJob?.cancel() 72 81 } 73 82 74 - fetchJob = viewModelScope.launch { 83 + timelineFetchJob = viewModelScope.launch { 75 84 bskyConn.fetchTimeline({ 76 85 if (uiState.selectedFeed == "Following") { 77 86 "" 78 87 } else { 79 88 uiState.selectedFeed 80 89 } 81 - }(), uiState.cursor).onSuccess { it -> 90 + }(), uiState.timelineCursor).onSuccess { it -> 82 91 uiState = uiState.copy( 83 92 skeets = (uiState.skeets + it.feed.map { SkeetData.fromFeedViewPost(it) }).distinctBy { it.cid }, 84 - cursor = it.cursor, 93 + timelineCursor = it.cursor, 85 94 isFetchingMoreTimeline = false 86 95 ) 87 96 then() ··· 100 109 } 101 110 } 102 111 112 + fun fetchNotifications(then: () -> Unit = {}) { 113 + uiState = uiState.copy(isFetchingMoreNotifications = true) 114 + runCatching { 115 + notificationsFetchJob?.cancel() 116 + } 117 + 118 + notificationsFetchJob = viewModelScope.launch { 119 + bskyConn.notifications(uiState.notificationsCursor, Clock.System.now()) 120 + .onSuccess { it -> 121 + uiState = uiState.copy( 122 + notifications = it.notifications, 123 + notificationsCursor = it.cursor, 124 + isFetchingMoreNotifications = false, 125 + lastDownloadedNotifs = Clock.System.now() 126 + ) 127 + then() 128 + }.onFailure { 129 + if (it is CancellationException) { 130 + return@onFailure 131 + } 132 + 133 + then() 134 + 135 + uiState = uiState.copy( 136 + isFetchingMoreNotifications = false, 137 + error = "Failed to fetch notifications: ${it.message}" 138 + ) 139 + } 140 + } 141 + } 142 + 103 143 fun reset() { 104 144 uiState = uiState.copy( 105 - skeets = listOf(), isFetchingMoreTimeline = false, cursor = null, 145 + skeets = listOf(), 146 + isFetchingMoreTimeline = false, 147 + timelineCursor = null, 148 + notificationsCursor = null, 149 + notifications = listOf() 106 150 ) 107 151 } 108 152