A cheap attempt at a native Bluesky client for Android

*: stop flickering on pulldown to refresh

Also unify refresh indicator on startup, so that we see both notifications and timeline

Remove responsibility for first refresh from tab view controllers

+78 -50
-11
.idea/deploymentTargetSelector.xml
··· 13 13 </DropdownSelection> 14 14 <DialogSelection /> 15 15 </SelectionState> 16 - <SelectionState runConfigName="app (release)"> 17 - <option name="selectionMode" value="DROPDOWN" /> 18 - <DropdownSelection timestamp="2025-10-13T14:56:43.774060Z"> 19 - <Target type="DEFAULT_BOOT"> 20 - <handle> 21 - <DeviceId pluginId="LocalEmulator" identifier="path=/Users/gsora/.android/avd/Medium_Phone.avd" /> 22 - </handle> 23 - </Target> 24 - </DropdownSelection> 25 - <DialogSelection /> 26 - </SelectionState> 27 16 </selectionStates> 28 17 </component> 29 18 </project>
+24 -9
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 164 164 val drawerState = rememberDrawerState( 165 165 initialValue = DrawerValue.Closed 166 166 ) 167 + val isScrollEnabled = remember { mutableStateOf(true) } 167 168 val ctx = LocalContext.current 168 169 169 170 LaunchedEffect(Unit) { ··· 193 194 isRefreshing.value = true 194 195 when (currentDestination) { 195 196 TabBarDestinations.TIMELINE -> { 196 - timelineViewModel.reset() 197 - timelineViewModel.fetchTimeline { isRefreshing.value = false } 197 + isScrollEnabled.value = false 198 + timelineViewModel.fetchTimeline { 199 + isRefreshing.value = false 200 + isScrollEnabled.value = true 201 + } 198 202 } 199 203 200 204 TabBarDestinations.NOTIFICATIONS -> { 201 - timelineViewModel.reset() 202 - timelineViewModel.fetchNotifications { isRefreshing.value = false } 205 + isScrollEnabled.value = false 206 + timelineViewModel.fetchNotifications { 207 + isRefreshing.value = false 208 + isScrollEnabled.value = true 209 + } 203 210 } 204 211 } 205 212 }, ··· 351 358 } 352 359 } 353 360 ) { values -> 361 + LaunchedEffect(Unit) { 362 + isScrollEnabled.value = false 363 + timelineViewModel.fetchNewData { 364 + isRefreshing.value = false 365 + isScrollEnabled.value = true 366 + } 367 + } 368 + 354 369 when (currentDestination) { 355 370 TabBarDestinations.TIMELINE -> ShowSkeets( 356 371 viewModel = timelineViewModel, 357 372 state = timelineState, 358 373 modifier = Modifier.padding(values), 359 - onReplyTap = onReplyTap 360 - ) { isRefreshing.value = false } 374 + onReplyTap = onReplyTap, 375 + isScrollEnabled = isScrollEnabled 376 + ) 361 377 362 378 TabBarDestinations.NOTIFICATIONS -> NotificationsView( 363 379 viewModel = timelineViewModel, 364 380 state = notificationsState, 365 381 modifier = Modifier.padding(values), 382 + isScrollEnabled = isScrollEnabled, 366 383 onReplyTap = onReplyTap 367 - ) { 368 - isRefreshing.value = false 369 - } 384 + ) 370 385 } 371 386 } 372 387 }
+3 -9
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 11 11 import androidx.compose.material3.CircularProgressIndicator 12 12 import androidx.compose.runtime.Composable 13 13 import androidx.compose.runtime.LaunchedEffect 14 + import androidx.compose.runtime.MutableState 14 15 import androidx.compose.runtime.derivedStateOf 15 16 import androidx.compose.runtime.getValue 16 17 import androidx.compose.runtime.remember ··· 28 29 viewModel: TimelineViewModel, 29 30 state: LazyListState, 30 31 modifier: Modifier = Modifier, 32 + isScrollEnabled: MutableState<Boolean>, 31 33 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 32 - doneRefresh: () -> Unit = {}, 33 34 ) { 34 - LaunchedEffect(key1 = viewModel.uiState.notifications.isEmpty()) { 35 - if (viewModel.uiState.notifications.isEmpty()) { 36 - viewModel.fetchNotifications { 37 - doneRefresh() 38 - } 39 - } 40 - } 41 - 42 35 LazyColumn( 43 36 state = state, 44 37 modifier = modifier.fillMaxSize(), 38 + userScrollEnabled = isScrollEnabled.value, 45 39 verticalArrangement = Arrangement.spacedBy(8.dp), 46 40 ) { 47 41 viewModel.uiState.notifications.forEach { notif ->
+3 -9
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 15 15 import androidx.compose.material3.VerticalDivider 16 16 import androidx.compose.runtime.Composable 17 17 import androidx.compose.runtime.LaunchedEffect 18 + import androidx.compose.runtime.MutableState 18 19 import androidx.compose.runtime.derivedStateOf 19 20 import androidx.compose.runtime.getValue 20 21 import androidx.compose.runtime.remember ··· 29 30 fun ShowSkeets( 30 31 modifier: Modifier = Modifier, 31 32 viewModel: TimelineViewModel, 33 + isScrollEnabled: MutableState<Boolean>, 32 34 state: LazyListState = rememberLazyListState(), 33 35 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 34 - doneFirstRefresh: () -> Unit = {} 35 36 ) { 36 - LaunchedEffect(key1 = viewModel.uiState.skeets.isEmpty()) { 37 - if (viewModel.uiState.skeets.isEmpty()) { 38 - viewModel.fetchTimeline { 39 - doneFirstRefresh() 40 - } 41 - } 42 - } 43 - 44 37 LazyColumn( 45 38 state = state, 39 + userScrollEnabled = isScrollEnabled.value, 46 40 modifier = modifier.fillMaxSize(), 47 41 verticalArrangement = Arrangement.spacedBy(8.dp), 48 42 ) {
+48 -12
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 99 99 return Result.success(ret) 100 100 } 101 101 102 - fun fetchTimeline(then: () -> Unit = {}) { 102 + fun fetchNewData(then: () -> Unit = {}) { 103 + fetchTimeline(fresh = true) 104 + fetchNotifications(fresh = true) 105 + viewModelScope.launch { 106 + timelineFetchJob?.join() 107 + notificationsFetchJob?.join() 108 + then() 109 + } 110 + } 111 + 112 + fun fetchTimeline(fresh: Boolean = false, then: () -> Unit = {}) { 103 113 uiState = uiState.copy(isFetchingMoreTimeline = true) 104 114 runCatching { 105 115 timelineFetchJob?.cancel() 106 116 } 107 117 108 118 timelineFetchJob = viewModelScope.launch { 109 - bskyConn.fetchTimeline({ 110 - if (uiState.selectedFeed == "Following") { 111 - "" 119 + bskyConn.fetchTimeline( 120 + { 121 + if (uiState.selectedFeed == "Following") { 122 + "" 123 + } else { 124 + uiState.selectedFeed 125 + } 126 + }(), if (fresh) { 127 + null 112 128 } else { 113 - uiState.selectedFeed 129 + uiState.timelineCursor 114 130 } 115 - }(), uiState.timelineCursor).onSuccess { it -> 131 + ).onSuccess { it -> 132 + if (fresh) { 133 + uiState = uiState.copy(skeets = listOf()) 134 + } 116 135 val newData = 117 136 (uiState.skeets + it.feed.map { SkeetData.fromFeedViewPost(it) }).distinctBy { it.cid } 118 137 ··· 137 156 } 138 157 } 139 158 140 - fun fetchNotifications(then: () -> Unit = {}) { 159 + fun fetchNotifications(fresh: Boolean = false, then: () -> Unit = {}) { 141 160 uiState = uiState.copy(isFetchingMoreNotifications = true) 142 161 runCatching { 143 162 notificationsFetchJob?.cancel() 144 163 } 145 164 146 165 notificationsFetchJob = viewModelScope.launch { 147 - val rawNotifs = bskyConn.notifications(uiState.notificationsCursor) 166 + val rawNotifs = bskyConn.notifications( 167 + if (fresh) { 168 + null 169 + } else { 170 + uiState.notificationsCursor 171 + } 172 + ) 148 173 .onFailure { 149 174 if (it is CancellationException) { 150 175 return@onFailure ··· 195 220 null 196 221 } 197 222 } 223 + } 224 + 225 + if (fresh) { 226 + uiState = uiState.copy(notifications = listOf()) 198 227 } 199 228 200 229 uiState = uiState.copy( ··· 206 235 } 207 236 } 208 237 209 - fun reset() { 238 + fun resetTimeline() { 210 239 uiState = uiState.copy( 211 240 skeets = listOf(), 212 241 isFetchingMoreTimeline = false, 213 242 timelineCursor = null, 243 + ) 244 + } 245 + 246 + fun resetNotifications() { 247 + uiState = uiState.copy( 248 + notifications = listOf(), 249 + isFetchingMoreNotifications = false, 214 250 notificationsCursor = null, 215 - notifications = listOf() 216 251 ) 217 252 } 253 + 218 254 219 255 suspend fun post( 220 256 content: String, ··· 244 280 } 245 281 } 246 282 } 247 - 283 + 248 284 fun selectFeed(uri: String, displayName: String, avatar: String?, then: () -> Unit = {}) { 249 285 uiState = uiState.copy( 250 286 selectedFeed = uri, 251 287 feedName = displayName, 252 288 feedAvatar = avatar, 253 289 ) 254 - reset() 290 + resetTimeline() 255 291 fetchTimeline { then() } 256 292 } 257 293