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

Configure Feed

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

SearchView: Move from: filter to top bar, hide People tab when active

Replace the from:user chip with a PersonSearch icon in the top bar
actions. Tapping it opens a dialog to enter a handle; when active, the
top bar title shows from:handle and the People tab is hidden. Tapping
the icon again clears the filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

geesawra d105927a 489b7e52

+77 -91
+71 -2
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 27 27 import androidx.compose.material.icons.filled.Create 28 28 import androidx.compose.material.icons.filled.Home 29 29 import androidx.compose.material.icons.filled.Notifications 30 + import androidx.compose.material.icons.filled.PersonSearch 30 31 import androidx.compose.material.icons.filled.Search 31 32 import androidx.compose.material.icons.filled.Tag 32 33 import androidx.compose.material3.Badge ··· 85 86 import coil3.compose.AsyncImage 86 87 import coil3.request.ImageRequest 87 88 import coil3.request.crossfade 89 + import androidx.compose.material3.AlertDialog 90 + import androidx.compose.material3.OutlinedTextField 91 + import androidx.compose.material3.TextButton 88 92 import industries.geesawra.monarch.datalayer.SkeetData 89 93 import industries.geesawra.monarch.datalayer.TimelineViewModel 90 94 import kotlinx.coroutines.CoroutineScope ··· 345 349 Text(text = timelineViewModel.uiState.feedName) 346 350 } 347 351 348 - TabBarDestinations.SEARCH -> Text(text = "Search") 352 + TabBarDestinations.SEARCH -> { 353 + val authorFilter = timelineViewModel.uiState.searchAuthorFilter 354 + if (authorFilter != null) { 355 + Text(text = "from:$authorFilter") 356 + } else { 357 + Text(text = "Search") 358 + } 359 + } 349 360 350 361 TabBarDestinations.NOTIFICATIONS -> Row( 351 362 horizontalArrangement = Arrangement.spacedBy(8.dp), ··· 396 407 } 397 408 } 398 409 399 - TabBarDestinations.SEARCH -> {} 410 + TabBarDestinations.SEARCH -> { 411 + val authorFilter = timelineViewModel.uiState.searchAuthorFilter 412 + if (authorFilter != null) { 413 + IconButton(onClick = { 414 + timelineViewModel.setSearchAuthorFilter(null) 415 + }) { 416 + Icon(Icons.Default.PersonSearch, "Remove author filter") 417 + } 418 + } else { 419 + var showFromDialog by remember { mutableStateOf(false) } 420 + IconButton(onClick = { showFromDialog = true }) { 421 + Icon(Icons.Default.PersonSearch, "Filter by author") 422 + } 423 + if (showFromDialog) { 424 + SearchFromAuthorDialog( 425 + onDismiss = { showFromDialog = false }, 426 + onConfirm = { handle -> 427 + timelineViewModel.setSearchAuthorFilter(handle) 428 + showFromDialog = false 429 + }, 430 + ) 431 + } 432 + } 433 + } 400 434 TabBarDestinations.NOTIFICATIONS -> {} 401 435 } 402 436 } ··· 637 671 ) 638 672 } 639 673 } 674 + 675 + @Composable 676 + fun SearchFromAuthorDialog( 677 + onDismiss: () -> Unit, 678 + onConfirm: (String) -> Unit, 679 + ) { 680 + var handle by remember { mutableStateOf("") } 681 + 682 + AlertDialog( 683 + onDismissRequest = onDismiss, 684 + title = { Text("Filter by author") }, 685 + text = { 686 + OutlinedTextField( 687 + value = handle, 688 + onValueChange = { handle = it }, 689 + label = { Text("Handle") }, 690 + placeholder = { Text("e.g. alice.bsky.social") }, 691 + singleLine = true, 692 + modifier = Modifier.fillMaxWidth(), 693 + ) 694 + }, 695 + confirmButton = { 696 + TextButton( 697 + onClick = { if (handle.isNotBlank()) onConfirm(handle.trim()) }, 698 + ) { 699 + Text("Apply") 700 + } 701 + }, 702 + dismissButton = { 703 + TextButton(onClick = onDismiss) { 704 + Text("Cancel") 705 + } 706 + }, 707 + ) 708 + }
+6 -89
app/src/main/java/industries/geesawra/monarch/SearchView.kt
··· 22 22 import androidx.compose.material.icons.filled.Clear 23 23 import androidx.compose.material.icons.filled.Search 24 24 import androidx.compose.material.icons.filled.Verified 25 - import androidx.compose.material3.AlertDialog 26 25 import androidx.compose.material3.Card 27 26 import androidx.compose.material3.CircularProgressIndicator 28 27 import androidx.compose.material3.ExperimentalMaterial3Api 29 - import androidx.compose.material3.FilterChip 30 28 import androidx.compose.material3.Icon 31 29 import androidx.compose.material3.IconButton 32 30 import androidx.compose.material3.MaterialTheme 33 - import androidx.compose.material3.OutlinedTextField 34 31 import androidx.compose.material3.PrimaryTabRow 35 32 import androidx.compose.material3.SearchBar 36 33 import androidx.compose.material3.SearchBarDefaults 37 34 import androidx.compose.material3.Tab 38 35 import androidx.compose.material3.Text 39 - import androidx.compose.material3.TextButton 40 36 import androidx.compose.runtime.Composable 41 37 import androidx.compose.runtime.LaunchedEffect 42 38 import androidx.compose.runtime.derivedStateOf ··· 117 113 .padding(horizontal = 16.dp), 118 114 ) {} 119 115 120 - // from: author filter row 121 - AuthorFilterRow(viewModel) 122 - 123 116 // Tabs 124 117 SearchTabs( 118 + hidePeople = viewModel.uiState.searchAuthorFilter != null, 125 119 selectedTab = selectedTab, 126 120 onTabSelected = { index -> 127 121 selectedTab = index ··· 152 146 } 153 147 } 154 148 155 - private val searchTabs = listOf("Latest", "Top", "People") 156 - 157 149 @OptIn(ExperimentalMaterial3Api::class) 158 150 @Composable 159 151 private fun SearchTabs( 152 + hidePeople: Boolean = false, 160 153 selectedTab: Int, 161 154 onTabSelected: (Int) -> Unit, 162 155 ) { 163 - PrimaryTabRow(selectedTabIndex = selectedTab) { 164 - searchTabs.forEachIndexed { index, label -> 156 + val tabs = if (hidePeople) listOf("Latest", "Top") else listOf("Latest", "Top", "People") 157 + 158 + PrimaryTabRow(selectedTabIndex = selectedTab.coerceAtMost(tabs.lastIndex)) { 159 + tabs.forEachIndexed { index, label -> 165 160 Tab( 166 161 selected = selectedTab == index, 167 162 onClick = { onTabSelected(index) }, ··· 171 166 } 172 167 } 173 168 174 - @Composable 175 - private fun AuthorFilterRow(viewModel: TimelineViewModel) { 176 - val authorFilter = viewModel.uiState.searchAuthorFilter 177 - var showDialog by remember { mutableStateOf(false) } 178 - 179 - if (showDialog) { 180 - FromAuthorDialog( 181 - onDismiss = { showDialog = false }, 182 - onConfirm = { handle -> 183 - viewModel.setSearchAuthorFilter(handle) 184 - showDialog = false 185 - }, 186 - ) 187 - } 188 - 189 - Row( 190 - modifier = Modifier 191 - .fillMaxWidth() 192 - .padding(horizontal = 16.dp, vertical = 4.dp), 193 - horizontalArrangement = Arrangement.spacedBy(8.dp), 194 - ) { 195 - if (authorFilter != null) { 196 - FilterChip( 197 - selected = true, 198 - onClick = { viewModel.setSearchAuthorFilter(null) }, 199 - label = { Text("from:$authorFilter") }, 200 - trailingIcon = { 201 - Icon( 202 - Icons.Default.Clear, 203 - contentDescription = "Remove author filter", 204 - modifier = Modifier.size(16.dp), 205 - ) 206 - }, 207 - ) 208 - } else { 209 - FilterChip( 210 - selected = false, 211 - onClick = { showDialog = true }, 212 - label = { Text("from:user") }, 213 - ) 214 - } 215 - } 216 - } 217 - 218 - @Composable 219 - private fun FromAuthorDialog( 220 - onDismiss: () -> Unit, 221 - onConfirm: (String) -> Unit, 222 - ) { 223 - var handle by remember { mutableStateOf("") } 224 - 225 - AlertDialog( 226 - onDismissRequest = onDismiss, 227 - title = { Text("Filter by author") }, 228 - text = { 229 - OutlinedTextField( 230 - value = handle, 231 - onValueChange = { handle = it }, 232 - label = { Text("Handle") }, 233 - placeholder = { Text("e.g. alice.bsky.social") }, 234 - singleLine = true, 235 - modifier = Modifier.fillMaxWidth(), 236 - ) 237 - }, 238 - confirmButton = { 239 - TextButton( 240 - onClick = { if (handle.isNotBlank()) onConfirm(handle.trim()) }, 241 - ) { 242 - Text("Apply") 243 - } 244 - }, 245 - dismissButton = { 246 - TextButton(onClick = onDismiss) { 247 - Text("Cancel") 248 - } 249 - }, 250 - ) 251 - } 252 169 253 170 @Composable 254 171 private fun SearchPostsResults(