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.

*: Add settings page with theme, text size, avatar shape, and content options

New SettingsView with 6 persisted settings: theme mode (system/light/dark),
dynamic color toggle, post text size (S/M/L), avatar shape (circle/rounded),
reply filtering (none/normal/strict), and show labels toggle. Settings are
backed by DataStore and apply immediately across all views.

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

geesawra d058981c c7f125b4

+507 -60
+53 -50
app/src/main/java/industries/geesawra/monarch/LikeRepostRowView.kt
··· 15 15 import androidx.compose.foundation.layout.Arrangement 16 16 import androidx.compose.foundation.layout.Box 17 17 import androidx.compose.foundation.layout.Column 18 - import androidx.compose.foundation.layout.ExperimentalLayoutApi 19 - import androidx.compose.foundation.layout.FlowRow 20 18 import androidx.compose.foundation.layout.Row 21 19 import androidx.compose.foundation.layout.Spacer 22 20 import androidx.compose.foundation.layout.fillMaxWidth ··· 26 24 import androidx.compose.foundation.layout.width 27 25 import androidx.compose.foundation.layout.wrapContentHeight 28 26 import androidx.compose.foundation.shape.CircleShape 27 + import androidx.compose.foundation.shape.RoundedCornerShape 29 28 import androidx.compose.material.icons.Icons 30 29 import androidx.compose.material.icons.filled.KeyboardArrowUp 31 30 import androidx.compose.material.icons.filled.Repeat ··· 52 51 import coil3.compose.AsyncImage 53 52 import coil3.request.ImageRequest 54 53 import coil3.request.crossfade 54 + import industries.geesawra.monarch.datalayer.AvatarShape 55 + import industries.geesawra.monarch.datalayer.PostTextSize 55 56 import industries.geesawra.monarch.datalayer.RepeatableNotification 57 + import industries.geesawra.monarch.datalayer.SettingsState 56 58 import industries.geesawra.monarch.datalayer.RepeatedNotification 57 59 import industries.geesawra.monarch.datalayer.SkeetData 58 60 import nl.jacobras.humanreadable.HumanReadable 61 + import sh.christian.ozone.api.Did 59 62 import kotlin.time.ExperimentalTime 60 63 61 64 fun name(p: ProfileView): String { ··· 68 71 } 69 72 } 70 73 71 - @OptIn(ExperimentalTime::class, ExperimentalLayoutApi::class) 74 + @OptIn(ExperimentalTime::class) 72 75 @Composable 73 76 fun LikeRepostRowView( 74 77 modifier: Modifier = Modifier, 75 78 data: RepeatedNotification, 79 + settingsState: SettingsState = SettingsState(), 76 80 onShowThread: (SkeetData) -> Unit = {}, 81 + onProfileTap: ((Did) -> Unit)? = null, 77 82 ) { 78 83 val avatarSize = 28.dp 79 84 val showAvatars = remember { mutableStateOf(false) } 85 + val avatarClipShape = if (settingsState.avatarShape == AvatarShape.RoundedSquare) RoundedCornerShape(8.dp) else CircleShape 80 86 81 87 Column( 82 88 modifier = modifier ··· 159 165 }, label = "size transform" 160 166 ) { 161 167 when (it) { 162 - true -> Column { 163 - FlowRow( 164 - horizontalArrangement = Arrangement.spacedBy(8.dp), 165 - verticalArrangement = Arrangement.spacedBy(8.dp), 166 - modifier = Modifier 167 - .fillMaxWidth() 168 - .padding(bottom = 8.dp) 169 - ) { 170 - data.authors.take(8).forEach { 171 - Row( 172 - verticalAlignment = Alignment.CenterVertically, 173 - modifier = Modifier 174 - .clickable { 175 - Log.d( 176 - "LikeRepostRowView", 177 - "Clicked ${it.author.handle.handle}" 178 - ) 179 - }, 180 - ) { 181 - AsyncImage( 182 - model = ImageRequest.Builder(LocalContext.current) 183 - .data(it.author.avatar?.uri) 184 - .crossfade(true) 185 - .build(), 186 - placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 187 - contentDescription = "Avatar", 188 - modifier = Modifier 189 - .size(avatarSize) 190 - .border( 191 - width = 1.dp, 192 - color = MaterialTheme.colorScheme.surfaceContainerLow, 193 - shape = CircleShape 194 - ) 195 - .clip(CircleShape) 196 - ) 197 - Spacer(modifier = Modifier.width(4.dp)) 198 - Text( 199 - text = name(it.author), 200 - style = MaterialTheme.typography.labelMedium, 201 - fontWeight = FontWeight.Bold, 202 - ) 203 - } 204 - } 205 - } 168 + true -> Column( 169 + verticalArrangement = Arrangement.spacedBy(4.dp) 170 + ) { 206 171 IconButton( 207 172 modifier = Modifier.align(Alignment.End), 208 173 onClick = { ··· 214 179 contentDescription = "Close avatar list", 215 180 ) 216 181 } 182 + data.authors.take(8).forEach { 183 + Row( 184 + verticalAlignment = Alignment.CenterVertically, 185 + horizontalArrangement = Arrangement.Start, 186 + modifier = Modifier 187 + .fillMaxWidth() 188 + .clickable { 189 + onProfileTap?.invoke(it.author.did) 190 + }, 191 + ) { 192 + AsyncImage( 193 + model = ImageRequest.Builder(LocalContext.current) 194 + .data(it.author.avatar?.uri) 195 + .crossfade(true) 196 + .build(), 197 + placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 198 + contentDescription = "Avatar", 199 + modifier = Modifier 200 + .size(avatarSize + 4.dp) 201 + .border( 202 + width = 1.dp, 203 + color = MaterialTheme.colorScheme.surfaceContainerLow, 204 + shape = avatarClipShape 205 + ) 206 + .clip(avatarClipShape) 207 + ) 208 + Text( 209 + modifier = Modifier 210 + .fillMaxWidth() 211 + .padding(start = 8.dp), 212 + text = name(it.author), 213 + style = MaterialTheme.typography.bodyMedium, 214 + fontWeight = FontWeight.Bold, 215 + ) 216 + } 217 + } 217 218 } 218 219 219 220 false -> Box( ··· 245 246 .border( 246 247 width = 1.dp, 247 248 color = MaterialTheme.colorScheme.surfaceContainerLow, 248 - shape = CircleShape 249 + shape = avatarClipShape 249 250 ) 250 - .clip(CircleShape) 251 + .clip(avatarClipShape) 251 252 ) 252 253 } 253 254 } ··· 265 266 viewModel = null, 266 267 skeet = data.post, 267 268 nested = true, 268 - showLabels = false, 269 + showLabels = settingsState.showLabels, 270 + postTextSize = settingsState.postTextSize, 271 + avatarShape = avatarClipShape, 269 272 onShowThread = onShowThread, 270 273 ) 271 274 }
+31 -1
app/src/main/java/industries/geesawra/monarch/MainActivity.kt
··· 37 37 import coil3.network.okhttp.OkHttpNetworkFetcherFactory 38 38 import dagger.hilt.android.AndroidEntryPoint 39 39 import dagger.hilt.android.HiltAndroidApp 40 + import androidx.compose.foundation.isSystemInDarkTheme 40 41 import industries.geesawra.monarch.datalayer.BlueskyConn 42 + import industries.geesawra.monarch.datalayer.SettingsState 43 + import industries.geesawra.monarch.datalayer.SettingsViewModel 44 + import industries.geesawra.monarch.datalayer.ThemeMode 41 45 import industries.geesawra.monarch.datalayer.TimelineViewModel 42 46 import industries.geesawra.monarch.ui.theme.MonarchTheme 43 47 import sh.christian.ozone.api.Did ··· 51 55 Main, 52 56 ShowThread, 53 57 Profile, 58 + Settings, 54 59 } 55 60 56 61 @AndroidEntryPoint ··· 64 69 65 70 setContent { 66 71 val firstLoadDone = remember { mutableStateOf(false) } 72 + val settingsViewModel = hiltViewModel<SettingsViewModel>() 73 + val settings = settingsViewModel.settingsState 67 74 68 - MonarchTheme { 75 + val darkTheme = when (settings.themeMode) { 76 + ThemeMode.System -> isSystemInDarkTheme() 77 + ThemeMode.Light -> false 78 + ThemeMode.Dark -> true 79 + } 80 + 81 + MonarchTheme( 82 + darkTheme = darkTheme, 83 + dynamicColor = settings.dynamicColor, 84 + ) { 69 85 val context = LocalContext.current 70 86 SingletonImageLoader.setSafe { 71 87 ImageLoader.Builder(context) ··· 130 146 composable(route = ViewList.Main.name) { 131 147 MainView( 132 148 timelineViewModel = timelineViewModel, 149 + settingsState = settings, 133 150 coroutineScope = rememberCoroutineScope(), 134 151 onLoginError = { 135 152 navController.navigate(ViewList.Login.name) ··· 140 157 onProfileTap = { did -> 141 158 timelineViewModel.openProfile(did) 142 159 navController.navigate(ViewList.Profile.name) 160 + }, 161 + onSettingsTap = { 162 + navController.navigate(ViewList.Settings.name) 143 163 }, 144 164 onFirstLoad = { 145 165 if (firstLoadDone.value) { ··· 155 175 modifier = Modifier 156 176 .windowInsetsPadding(WindowInsets.statusBars), 157 177 timelineViewModel = timelineViewModel, 178 + settingsState = settings, 158 179 coroutineScope = rememberCoroutineScope(), 159 180 backButton = { 160 181 navController.popBackStack() ··· 168 189 composable(route = ViewList.Profile.name) { 169 190 ProfileView( 170 191 timelineViewModel = timelineViewModel, 192 + settingsState = settings, 171 193 coroutineScope = rememberCoroutineScope(), 172 194 backButton = { 173 195 navController.popBackStack() ··· 178 200 onProfileTap = { did -> 179 201 timelineViewModel.openProfile(did) 180 202 navController.navigate(ViewList.Profile.name) 203 + }, 204 + ) 205 + } 206 + composable(route = ViewList.Settings.name) { 207 + SettingsView( 208 + settingsViewModel = settingsViewModel, 209 + backButton = { 210 + navController.popBackStack() 181 211 }, 182 212 ) 183 213 }
+14
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 29 29 import androidx.compose.material.icons.filled.Notifications 30 30 import androidx.compose.material.icons.filled.PersonSearch 31 31 import androidx.compose.material.icons.filled.Search 32 + import androidx.compose.material.icons.filled.Settings 32 33 import androidx.compose.material.icons.filled.Tag 33 34 import androidx.compose.material3.Badge 34 35 import androidx.compose.material3.BadgedBox ··· 89 90 import androidx.compose.material3.AlertDialog 90 91 import androidx.compose.material3.OutlinedTextField 91 92 import androidx.compose.material3.TextButton 93 + import industries.geesawra.monarch.datalayer.SettingsState 92 94 import industries.geesawra.monarch.datalayer.SkeetData 93 95 import industries.geesawra.monarch.datalayer.TimelineViewModel 94 96 import kotlinx.coroutines.CoroutineScope ··· 125 127 @Composable 126 128 fun MainView( 127 129 timelineViewModel: TimelineViewModel, 130 + settingsState: SettingsState, 128 131 coroutineScope: CoroutineScope, 129 132 onLoginError: () -> Unit, 130 133 onThreadTap: (SkeetData) -> Unit, 131 134 onProfileTap: (Did) -> Unit, 135 + onSettingsTap: () -> Unit, 132 136 onFirstLoad: () -> Unit, 133 137 ) { 134 138 val scrollState = rememberScrollState() ··· 173 177 modifier = Modifier.padding(paddingValues), 174 178 coroutineScope = coroutineScope, 175 179 timelineViewModel = timelineViewModel, 180 + settingsState = settingsState, 176 181 onProfileTap = onProfileTap, 182 + onSettingsTap = onSettingsTap, 177 183 fobOnClick = { 178 184 coroutineScope.launch { 179 185 scaffoldState.bottomSheetState.expand() ··· 209 215 modifier: Modifier = Modifier, 210 216 coroutineScope: CoroutineScope, 211 217 timelineViewModel: TimelineViewModel, 218 + settingsState: SettingsState, 212 219 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 213 220 onProfileTap: (Did) -> Unit = {}, 221 + onSettingsTap: () -> Unit = {}, 214 222 fobOnClick: () -> Unit, 215 223 loginError: () -> Unit, 216 224 onError: (String) -> Unit, ··· 388 396 } 389 397 }, 390 398 actions = { 399 + IconButton(onClick = onSettingsTap) { 400 + Icon(Icons.Default.Settings, "Settings") 401 + } 391 402 when (currentDestination) { 392 403 TabBarDestinations.TIMELINE -> { 393 404 if (timelineViewModel.uiState.user == null) { ··· 584 595 when (currentDestination) { 585 596 TabBarDestinations.TIMELINE -> ShowSkeets( 586 597 viewModel = timelineViewModel, 598 + settingsState = settingsState, 587 599 state = timelineState, 588 600 modifier = Modifier.padding(values), 589 601 onReplyTap = onReplyTap, ··· 608 620 609 621 TabBarDestinations.NOTIFICATIONS -> NotificationsView( 610 622 viewModel = timelineViewModel, 623 + settingsState = settingsState, 611 624 state = notificationsState, 612 625 modifier = Modifier, 613 626 isScrollEnabled = isScrollEnabled, 614 627 onReplyTap = onReplyTap, 628 + onProfileTap = onProfileTap, 615 629 scaffoldPadding = values, 616 630 onSeeMoreTap = onSeeMoreTap 617 631 )
+31 -1
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 27 27 import androidx.compose.ui.Alignment 28 28 import androidx.compose.ui.Modifier 29 29 import androidx.compose.ui.graphics.ColorFilter 30 + import androidx.compose.foundation.shape.CircleShape 31 + import androidx.compose.foundation.shape.RoundedCornerShape 30 32 import androidx.compose.ui.unit.dp 33 + import industries.geesawra.monarch.datalayer.AvatarShape 31 34 import industries.geesawra.monarch.datalayer.Notification 35 + import industries.geesawra.monarch.datalayer.PostTextSize 36 + import industries.geesawra.monarch.datalayer.SettingsState 32 37 import industries.geesawra.monarch.datalayer.SkeetData 33 38 import industries.geesawra.monarch.datalayer.TimelineViewModel 34 39 import kotlinx.coroutines.delay 40 + import sh.christian.ozone.api.Did 35 41 import kotlin.time.ExperimentalTime 36 42 37 43 @ExperimentalTime ··· 41 47 state: LazyListState, 42 48 modifier: Modifier = Modifier, 43 49 isScrollEnabled: Boolean, 50 + settingsState: SettingsState = SettingsState(), 44 51 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 45 52 onSeeMoreTap: ((SkeetData) -> Unit)? = null, 53 + onProfileTap: ((Did) -> Unit)? = null, 46 54 scaffoldPadding: PaddingValues 47 55 ) { 48 56 LaunchedEffect(Unit) { ··· 75 83 RenderNotification( 76 84 viewModel = viewModel, 77 85 notification = notif, 86 + settingsState = settingsState, 78 87 onReplyTap = onReplyTap, 88 + onProfileTap = onProfileTap, 79 89 onShowThread = { skeet -> 80 90 if (onSeeMoreTap != null) { 81 91 viewModel.setThread(skeet) ··· 112 122 private fun RenderNotification( 113 123 viewModel: TimelineViewModel, 114 124 notification: Notification, 125 + settingsState: SettingsState = SettingsState(), 115 126 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 127 + onProfileTap: ((Did) -> Unit)? = null, 116 128 onShowThread: (SkeetData) -> Unit = {}, 117 129 ) { 130 + val avatarClipShape = if (settingsState.avatarShape == AvatarShape.RoundedSquare) RoundedCornerShape(8.dp) else CircleShape 131 + 118 132 when (notification) { 119 133 is Notification.Follow -> { 120 134 Column { ··· 163 177 authorHandle = notification.follow.handle, 164 178 content = notification.follow.description ?: "" 165 179 ), 166 - nested = true 180 + nested = true, 181 + postTextSize = settingsState.postTextSize, 182 + avatarShape = avatarClipShape, 183 + showLabels = settingsState.showLabels, 167 184 ) 168 185 } 169 186 } ··· 171 188 172 189 is Notification.Like -> LikeRepostRowView( 173 190 data = notification.data, 191 + settingsState = settingsState, 174 192 onShowThread = onShowThread, 193 + onProfileTap = onProfileTap, 175 194 ) 176 195 177 196 is Notification.Mention -> SkeetView( ··· 182 201 notification.author, 183 202 ), 184 203 onReplyTap = onReplyTap, 204 + postTextSize = settingsState.postTextSize, 205 + avatarShape = avatarClipShape, 206 + showLabels = settingsState.showLabels, 185 207 renderingMention = true, 186 208 ) 187 209 ··· 194 216 notification.quotedPost 195 217 ), 196 218 onReplyTap = onReplyTap, 219 + postTextSize = settingsState.postTextSize, 220 + avatarShape = avatarClipShape, 221 + showLabels = settingsState.showLabels, 197 222 ) 198 223 199 224 is Notification.Reply -> SkeetView( ··· 204 229 notification.author 205 230 ), 206 231 onReplyTap = onReplyTap, 232 + postTextSize = settingsState.postTextSize, 233 + avatarShape = avatarClipShape, 234 + showLabels = settingsState.showLabels, 207 235 renderingReplyNotif = true, 208 236 ) 209 237 210 238 is Notification.Repost -> LikeRepostRowView( 211 239 data = notification.data, 240 + settingsState = settingsState, 212 241 onShowThread = onShowThread, 242 + onProfileTap = onProfileTap, 213 243 ) 214 244 215 245
+11
app/src/main/java/industries/geesawra/monarch/ProfileView.kt
··· 32 32 import androidx.compose.foundation.lazy.rememberLazyListState 33 33 import androidx.compose.foundation.rememberScrollState 34 34 import androidx.compose.foundation.shape.CircleShape 35 + import androidx.compose.foundation.shape.RoundedCornerShape 35 36 import androidx.compose.foundation.text.KeyboardOptions 36 37 import androidx.compose.foundation.verticalScroll 37 38 import androidx.compose.material.icons.Icons ··· 91 92 import coil3.compose.AsyncImage 92 93 import coil3.request.ImageRequest 93 94 import coil3.request.crossfade 95 + import industries.geesawra.monarch.datalayer.AvatarShape 96 + import industries.geesawra.monarch.datalayer.PostTextSize 97 + import industries.geesawra.monarch.datalayer.SettingsState 94 98 import industries.geesawra.monarch.datalayer.SkeetData 95 99 import industries.geesawra.monarch.datalayer.TimelineViewModel 96 100 import kotlinx.coroutines.CoroutineScope ··· 103 107 fun ProfileView( 104 108 modifier: Modifier = Modifier, 105 109 timelineViewModel: TimelineViewModel, 110 + settingsState: SettingsState = SettingsState(), 106 111 coroutineScope: CoroutineScope, 107 112 backButton: () -> Unit, 108 113 onThreadTap: (SkeetData) -> Unit, ··· 208 213 modifier = Modifier.padding(padding), 209 214 profile = profile, 210 215 timelineViewModel = timelineViewModel, 216 + settingsState = settingsState, 211 217 listState = listState, 212 218 onThreadTap = onThreadTap, 213 219 onProfileTap = onProfileTap, ··· 245 251 modifier: Modifier = Modifier, 246 252 profile: ProfileViewDetailed, 247 253 timelineViewModel: TimelineViewModel, 254 + settingsState: SettingsState = SettingsState(), 248 255 listState: LazyListState, 249 256 onThreadTap: (SkeetData) -> Unit, 250 257 onProfileTap: (Did) -> Unit, 251 258 ) { 252 259 val posts = timelineViewModel.uiState.profilePosts 260 + val avatarClipShape = if (settingsState.avatarShape == AvatarShape.RoundedSquare) RoundedCornerShape(8.dp) else CircleShape 253 261 254 262 LazyColumn( 255 263 state = listState, ··· 281 289 viewModel = timelineViewModel, 282 290 skeet = skeet, 283 291 onReplyTap = { _, _ -> }, 292 + postTextSize = settingsState.postTextSize, 293 + avatarShape = avatarClipShape, 294 + showLabels = settingsState.showLabels, 284 295 onShowThread = { s -> 285 296 timelineViewModel.setThread(s) 286 297 onThreadTap(s)
+216
app/src/main/java/industries/geesawra/monarch/SettingsView.kt
··· 1 + package industries.geesawra.monarch 2 + 3 + import android.os.Build 4 + import androidx.compose.foundation.layout.Column 5 + import androidx.compose.foundation.layout.WindowInsets 6 + import androidx.compose.foundation.layout.fillMaxSize 7 + import androidx.compose.foundation.layout.padding 8 + import androidx.compose.foundation.layout.statusBars 9 + import androidx.compose.foundation.layout.windowInsetsPadding 10 + import androidx.compose.foundation.rememberScrollState 11 + import androidx.compose.foundation.verticalScroll 12 + import androidx.compose.material.icons.Icons 13 + import androidx.compose.material.icons.automirrored.filled.ArrowBack 14 + import androidx.compose.material3.ExperimentalMaterial3Api 15 + import androidx.compose.material3.HorizontalDivider 16 + import androidx.compose.material3.Icon 17 + import androidx.compose.material3.IconButton 18 + import androidx.compose.material3.ListItem 19 + import androidx.compose.material3.MaterialTheme 20 + import androidx.compose.material3.Scaffold 21 + import androidx.compose.material3.SegmentedButton 22 + import androidx.compose.material3.SegmentedButtonDefaults 23 + import androidx.compose.material3.SingleChoiceSegmentedButtonRow 24 + import androidx.compose.material3.Switch 25 + import androidx.compose.material3.Text 26 + import androidx.compose.material3.TopAppBar 27 + import androidx.compose.material3.TopAppBarDefaults 28 + import androidx.compose.runtime.Composable 29 + import androidx.compose.ui.Modifier 30 + import androidx.compose.ui.unit.dp 31 + import industries.geesawra.monarch.datalayer.AvatarShape 32 + import industries.geesawra.monarch.datalayer.PostTextSize 33 + import industries.geesawra.monarch.datalayer.ReplyFilterMode 34 + import industries.geesawra.monarch.datalayer.SettingsViewModel 35 + import industries.geesawra.monarch.datalayer.ThemeMode 36 + 37 + @OptIn(ExperimentalMaterial3Api::class) 38 + @Composable 39 + fun SettingsView( 40 + settingsViewModel: SettingsViewModel, 41 + backButton: () -> Unit, 42 + ) { 43 + val settings = settingsViewModel.settingsState 44 + 45 + Scaffold( 46 + modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), 47 + topBar = { 48 + TopAppBar( 49 + title = { Text("Settings") }, 50 + colors = TopAppBarDefaults.topAppBarColors( 51 + containerColor = MaterialTheme.colorScheme.surface, 52 + ), 53 + navigationIcon = { 54 + IconButton(onClick = backButton) { 55 + Icon( 56 + imageVector = Icons.AutoMirrored.Filled.ArrowBack, 57 + contentDescription = "Go back" 58 + ) 59 + } 60 + }, 61 + ) 62 + }, 63 + ) { padding -> 64 + Column( 65 + modifier = Modifier 66 + .fillMaxSize() 67 + .padding(padding) 68 + .verticalScroll(rememberScrollState()) 69 + ) { 70 + Text( 71 + text = "Appearance", 72 + style = MaterialTheme.typography.titleSmall, 73 + color = MaterialTheme.colorScheme.primary, 74 + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp) 75 + ) 76 + 77 + ListItem( 78 + headlineContent = { Text("Theme") }, 79 + supportingContent = { 80 + SingleChoiceSegmentedButtonRow( 81 + modifier = Modifier.padding(top = 4.dp) 82 + ) { 83 + ThemeMode.entries.forEachIndexed { idx, mode -> 84 + SegmentedButton( 85 + selected = settings.themeMode == mode, 86 + onClick = { settingsViewModel.setThemeMode(mode) }, 87 + shape = SegmentedButtonDefaults.itemShape(idx, ThemeMode.entries.size), 88 + ) { 89 + Text(mode.name) 90 + } 91 + } 92 + } 93 + }, 94 + ) 95 + 96 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 97 + ListItem( 98 + headlineContent = { Text("Dynamic color") }, 99 + supportingContent = { Text("Use colors from your wallpaper") }, 100 + trailingContent = { 101 + Switch( 102 + checked = settings.dynamicColor, 103 + onCheckedChange = { settingsViewModel.setDynamicColor(it) } 104 + ) 105 + }, 106 + ) 107 + } 108 + 109 + ListItem( 110 + headlineContent = { Text("Post text size") }, 111 + supportingContent = { 112 + SingleChoiceSegmentedButtonRow( 113 + modifier = Modifier.padding(top = 4.dp) 114 + ) { 115 + PostTextSize.entries.forEachIndexed { idx, size -> 116 + SegmentedButton( 117 + selected = settings.postTextSize == size, 118 + onClick = { settingsViewModel.setPostTextSize(size) }, 119 + shape = SegmentedButtonDefaults.itemShape(idx, PostTextSize.entries.size), 120 + ) { 121 + Text( 122 + when (size) { 123 + PostTextSize.Small -> "S" 124 + PostTextSize.Medium -> "M" 125 + PostTextSize.Large -> "L" 126 + } 127 + ) 128 + } 129 + } 130 + } 131 + }, 132 + ) 133 + 134 + ListItem( 135 + headlineContent = { Text("Avatar shape") }, 136 + supportingContent = { 137 + SingleChoiceSegmentedButtonRow( 138 + modifier = Modifier.padding(top = 4.dp) 139 + ) { 140 + AvatarShape.entries.forEachIndexed { idx, shape -> 141 + SegmentedButton( 142 + selected = settings.avatarShape == shape, 143 + onClick = { settingsViewModel.setAvatarShape(shape) }, 144 + shape = SegmentedButtonDefaults.itemShape(idx, AvatarShape.entries.size), 145 + ) { 146 + Text( 147 + when (shape) { 148 + AvatarShape.Circle -> "Circle" 149 + AvatarShape.RoundedSquare -> "Rounded" 150 + } 151 + ) 152 + } 153 + } 154 + } 155 + }, 156 + ) 157 + 158 + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) 159 + 160 + Text( 161 + text = "Content", 162 + style = MaterialTheme.typography.titleSmall, 163 + color = MaterialTheme.colorScheme.primary, 164 + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp) 165 + ) 166 + 167 + ListItem( 168 + headlineContent = { Text("Reply filtering") }, 169 + supportingContent = { 170 + Column { 171 + Text( 172 + text = when (settings.replyFilterMode) { 173 + ReplyFilterMode.None -> "Show all replies" 174 + ReplyFilterMode.OnlyFilterDeepThreads -> "Hide deep thread noise" 175 + ReplyFilterMode.Strict -> "Only direct replies" 176 + }, 177 + style = MaterialTheme.typography.bodySmall, 178 + color = MaterialTheme.colorScheme.onSurfaceVariant, 179 + ) 180 + SingleChoiceSegmentedButtonRow( 181 + modifier = Modifier.padding(top = 4.dp) 182 + ) { 183 + val modes = ReplyFilterMode.entries 184 + modes.forEachIndexed { idx, mode -> 185 + SegmentedButton( 186 + selected = settings.replyFilterMode == mode, 187 + onClick = { settingsViewModel.setReplyFilterMode(mode) }, 188 + shape = SegmentedButtonDefaults.itemShape(idx, modes.size), 189 + ) { 190 + Text( 191 + when (mode) { 192 + ReplyFilterMode.None -> "None" 193 + ReplyFilterMode.OnlyFilterDeepThreads -> "Normal" 194 + ReplyFilterMode.Strict -> "Strict" 195 + } 196 + ) 197 + } 198 + } 199 + } 200 + } 201 + }, 202 + ) 203 + 204 + ListItem( 205 + headlineContent = { Text("Show labels") }, 206 + supportingContent = { Text("Show content labels on posts") }, 207 + trailingContent = { 208 + Switch( 209 + checked = settings.showLabels, 210 + onCheckedChange = { settingsViewModel.setShowLabels(it) } 211 + ) 212 + }, 213 + ) 214 + } 215 + } 216 + }
+15
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 16 16 import androidx.compose.foundation.lazy.LazyListState 17 17 import androidx.compose.foundation.lazy.itemsIndexed 18 18 import androidx.compose.foundation.lazy.rememberLazyListState 19 + import androidx.compose.foundation.shape.CircleShape 19 20 import androidx.compose.foundation.shape.RoundedCornerShape 20 21 import androidx.compose.material3.CardDefaults 21 22 import androidx.compose.material3.ElevatedCard ··· 31 32 import androidx.compose.ui.draw.clip 32 33 import androidx.compose.ui.unit.dp 33 34 import app.bsky.feed.FeedViewPostReasonUnion 35 + import industries.geesawra.monarch.datalayer.AvatarShape 36 + import industries.geesawra.monarch.datalayer.PostTextSize 37 + import industries.geesawra.monarch.datalayer.SettingsState 34 38 import industries.geesawra.monarch.datalayer.SkeetData 35 39 import sh.christian.ozone.api.Cid 36 40 import industries.geesawra.monarch.datalayer.TimelineViewModel ··· 45 49 data: List<SkeetData>, 46 50 isShowingThread: Boolean = false, 47 51 shouldFetchMoreData: Boolean = true, 52 + settingsState: SettingsState = SettingsState(), 48 53 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 49 54 onSeeMoreTap: ((SkeetData) -> Unit)? = null, 50 55 onProfileTap: ((Did) -> Unit)? = null, 51 56 ) { 57 + val avatarClipShape = if (settingsState.avatarShape == AvatarShape.RoundedSquare) RoundedCornerShape(8.dp) else CircleShape 52 58 // Collect CIDs already shown as thread context (root/parent) to avoid duplicates 53 59 val threadContextCids = remember(data) { 54 60 if (isShowingThread) emptySet() ··· 99 105 skeet = it, 100 106 onReplyTap = onReplyTap, 101 107 inThread = true, 108 + postTextSize = settingsState.postTextSize, 109 + avatarShape = avatarClipShape, 110 + showLabels = settingsState.showLabels, 102 111 onAvatarTap = onProfileTap, 103 112 onShowThread = { skeet -> 104 113 if (onSeeMoreTap != null) { ··· 152 161 skeet = it, 153 162 onReplyTap = onReplyTap, 154 163 inThread = true, 164 + postTextSize = settingsState.postTextSize, 165 + avatarShape = avatarClipShape, 166 + showLabels = settingsState.showLabels, 155 167 onAvatarTap = onProfileTap, 156 168 onShowThread = { skeet -> 157 169 if (onSeeMoreTap != null) { ··· 169 181 skeet = skeet, 170 182 onReplyTap = onReplyTap, 171 183 showInReplyTo = parent == null, 184 + postTextSize = settingsState.postTextSize, 185 + avatarShape = avatarClipShape, 186 + showLabels = settingsState.showLabels, 172 187 onAvatarTap = onProfileTap, 173 188 onShowThread = { skeet -> 174 189 if (onSeeMoreTap != null) {
+15 -5
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 57 57 import androidx.compose.ui.Alignment 58 58 import androidx.compose.ui.Modifier 59 59 import androidx.compose.ui.draw.clip 60 + import androidx.compose.ui.graphics.Shape 60 61 import androidx.compose.ui.graphics.vector.ImageVector 61 62 import androidx.compose.ui.layout.ContentScale 62 63 import androidx.compose.ui.platform.LocalContext ··· 79 80 import coil3.request.ImageRequest 80 81 import coil3.request.crossfade 81 82 import com.atproto.label.Label 83 + import industries.geesawra.monarch.datalayer.PostTextSize 82 84 import industries.geesawra.monarch.datalayer.SkeetData 83 85 import industries.geesawra.monarch.datalayer.TimelineViewModel 84 86 import sh.christian.ozone.api.Did ··· 101 103 inThread: Boolean = false, 102 104 showInReplyTo: Boolean = true, 103 105 showLabels: Boolean = true, 106 + postTextSize: PostTextSize = PostTextSize.Medium, 107 + avatarShape: Shape = CircleShape, 104 108 renderingReplyNotif: Boolean = false, 105 109 renderingMention: Boolean = false, 106 110 onShowThread: (SkeetData) -> Unit = {}, ··· 152 156 contentDescription = "Avatar", 153 157 modifier = Modifier 154 158 .size(minSize) 155 - .clip(CircleShape) 159 + .clip(avatarShape) 156 160 .then( 157 161 if (onAvatarTap != null && skeet.did != null) 158 162 Modifier.clickable { onAvatarTap(skeet.did!!) } ··· 170 174 ) 171 175 } 172 176 173 - SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap) 177 + SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize) 174 178 } 175 179 } else { 176 180 // Top-level posts: two-column layout, thread line spans full height ··· 200 204 contentDescription = "Avatar", 201 205 modifier = Modifier 202 206 .size(minSize) 203 - .clip(CircleShape) 207 + .clip(avatarShape) 204 208 .then( 205 209 if (onAvatarTap != null && skeet.did != null) 206 210 Modifier.clickable { onAvatarTap(skeet.did!!) } ··· 242 246 labelerAvatar = { viewModel?.labelerAvatar(it) } 243 247 ) 244 248 245 - SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap) 249 + SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize) 246 250 247 251 if (!disableEmbeds) { 248 252 TimelinePostActionsView( ··· 268 272 onShowThread: (SkeetData) -> Unit, 269 273 viewModel: TimelineViewModel? = null, 270 274 onMentionClick: ((Did) -> Unit)? = null, 275 + postTextSize: PostTextSize = PostTextSize.Medium, 271 276 ) { 272 277 val context = LocalContext.current 273 278 274 279 if (skeet.content.isNotEmpty()) { 280 + val textStyle = when (postTextSize) { 281 + PostTextSize.Small -> MaterialTheme.typography.bodySmall 282 + PostTextSize.Medium -> MaterialTheme.typography.bodyMedium 283 + PostTextSize.Large -> MaterialTheme.typography.bodyLarge 284 + } 275 285 Text( 276 286 text = skeet.annotatedContent(onMentionClick = onMentionClick), 277 287 color = MaterialTheme.colorScheme.onSurface, 278 - style = MaterialTheme.typography.bodyLarge, 288 + style = textStyle, 279 289 ) 280 290 } 281 291
+3
app/src/main/java/industries/geesawra/monarch/ThreadView.kt
··· 22 22 import androidx.compose.runtime.remember 23 23 import androidx.compose.ui.Modifier 24 24 import androidx.compose.ui.input.nestedscroll.nestedScroll 25 + import industries.geesawra.monarch.datalayer.SettingsState 25 26 import industries.geesawra.monarch.datalayer.TimelineViewModel 26 27 import kotlinx.coroutines.CoroutineScope 27 28 import sh.christian.ozone.api.Did ··· 31 32 fun ThreadView( 32 33 modifier: Modifier = Modifier, 33 34 timelineViewModel: TimelineViewModel, 35 + settingsState: SettingsState = SettingsState(), 34 36 backButton: () -> Unit, 35 37 coroutineScope: CoroutineScope, 36 38 onProfileTap: ((Did) -> Unit)? = null, ··· 91 93 data = timelineViewModel.uiState.currentlyShownThread.flatten(), 92 94 shouldFetchMoreData = false, 93 95 isShowingThread = true, 96 + settingsState = settingsState, 94 97 onProfileTap = onProfileTap, 95 98 ) 96 99 }
+115
app/src/main/java/industries/geesawra/monarch/datalayer/SettingsViewModel.kt
··· 1 + package industries.geesawra.monarch.datalayer 2 + 3 + import android.content.Context 4 + import androidx.compose.runtime.getValue 5 + import androidx.compose.runtime.mutableStateOf 6 + import androidx.compose.runtime.setValue 7 + import androidx.datastore.preferences.core.edit 8 + import androidx.datastore.preferences.core.stringPreferencesKey 9 + import androidx.datastore.preferences.preferencesDataStore 10 + import androidx.lifecycle.ViewModel 11 + import androidx.lifecycle.viewModelScope 12 + import dagger.hilt.android.lifecycle.HiltViewModel 13 + import dagger.hilt.android.qualifiers.ApplicationContext 14 + import jakarta.inject.Inject 15 + import kotlinx.coroutines.flow.map 16 + import kotlinx.coroutines.launch 17 + 18 + private val Context.settingsDataStore by preferencesDataStore("settings") 19 + 20 + enum class ThemeMode { 21 + System, 22 + Light, 23 + Dark, 24 + } 25 + 26 + enum class PostTextSize { 27 + Small, 28 + Medium, 29 + Large, 30 + } 31 + 32 + enum class AvatarShape { 33 + Circle, 34 + RoundedSquare, 35 + } 36 + 37 + data class SettingsState( 38 + val themeMode: ThemeMode = ThemeMode.System, 39 + val dynamicColor: Boolean = true, 40 + val postTextSize: PostTextSize = PostTextSize.Medium, 41 + val avatarShape: AvatarShape = AvatarShape.Circle, 42 + val replyFilterMode: ReplyFilterMode = ReplyFilterMode.OnlyFilterDeepThreads, 43 + val showLabels: Boolean = true, 44 + ) 45 + 46 + @HiltViewModel 47 + class SettingsViewModel @Inject constructor( 48 + @ApplicationContext private val context: Context 49 + ) : ViewModel() { 50 + 51 + companion object { 52 + private val THEME_MODE = stringPreferencesKey("theme_mode") 53 + private val DYNAMIC_COLOR = stringPreferencesKey("dynamic_color") 54 + private val POST_TEXT_SIZE = stringPreferencesKey("post_text_size") 55 + private val AVATAR_SHAPE = stringPreferencesKey("avatar_shape") 56 + private val REPLY_FILTER_MODE = stringPreferencesKey("reply_filter_mode") 57 + private val SHOW_LABELS = stringPreferencesKey("show_labels") 58 + } 59 + 60 + var settingsState by mutableStateOf(SettingsState()) 61 + private set 62 + 63 + init { 64 + viewModelScope.launch { 65 + context.settingsDataStore.data.map { prefs -> 66 + SettingsState( 67 + themeMode = prefs[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: ThemeMode.System, 68 + dynamicColor = prefs[DYNAMIC_COLOR]?.toBooleanStrictOrNull() ?: true, 69 + postTextSize = prefs[POST_TEXT_SIZE]?.let { runCatching { PostTextSize.valueOf(it) }.getOrNull() } ?: PostTextSize.Medium, 70 + avatarShape = prefs[AVATAR_SHAPE]?.let { runCatching { AvatarShape.valueOf(it) }.getOrNull() } ?: AvatarShape.Circle, 71 + replyFilterMode = prefs[REPLY_FILTER_MODE]?.let { runCatching { ReplyFilterMode.valueOf(it) }.getOrNull() } ?: ReplyFilterMode.OnlyFilterDeepThreads, 72 + showLabels = prefs[SHOW_LABELS]?.toBooleanStrictOrNull() ?: true, 73 + ) 74 + }.collect { 75 + settingsState = it 76 + } 77 + } 78 + } 79 + 80 + fun setThemeMode(mode: ThemeMode) { 81 + viewModelScope.launch { 82 + context.settingsDataStore.edit { it[THEME_MODE] = mode.name } 83 + } 84 + } 85 + 86 + fun setDynamicColor(enabled: Boolean) { 87 + viewModelScope.launch { 88 + context.settingsDataStore.edit { it[DYNAMIC_COLOR] = enabled.toString() } 89 + } 90 + } 91 + 92 + fun setPostTextSize(size: PostTextSize) { 93 + viewModelScope.launch { 94 + context.settingsDataStore.edit { it[POST_TEXT_SIZE] = size.name } 95 + } 96 + } 97 + 98 + fun setAvatarShape(shape: AvatarShape) { 99 + viewModelScope.launch { 100 + context.settingsDataStore.edit { it[AVATAR_SHAPE] = shape.name } 101 + } 102 + } 103 + 104 + fun setReplyFilterMode(mode: ReplyFilterMode) { 105 + viewModelScope.launch { 106 + context.settingsDataStore.edit { it[REPLY_FILTER_MODE] = mode.name } 107 + } 108 + } 109 + 110 + fun setShowLabels(show: Boolean) { 111 + viewModelScope.launch { 112 + context.settingsDataStore.edit { it[SHOW_LABELS] = show.toString() } 113 + } 114 + } 115 + }
+3 -3
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 174 174 } 175 175 } 176 176 177 - fun fetchTimeline(fresh: Boolean = false, then: () -> Unit = {}) { 177 + fun fetchTimeline(fresh: Boolean = false, replyFilterMode: ReplyFilterMode = ReplyFilterMode.OnlyFilterDeepThreads, then: () -> Unit = {}) { 178 178 uiState = uiState.copy(isFetchingMoreTimeline = true) 179 179 runCatching { 180 180 timelineFetchJob?.cancel() ··· 202 202 ) 203 203 }.onSuccess { response -> 204 204 val newSkeets = if (fresh) { 205 - response.feed.map { SkeetData.fromFeedViewPost(it, bskyConn.session?.did) }.distinctBy { it.cid } 205 + response.feed.map { SkeetData.fromFeedViewPost(it, bskyConn.session?.did, replyFilterMode) }.distinctBy { it.cid } 206 206 } else { 207 - (uiState.skeets + response.feed.map { SkeetData.fromFeedViewPost(it, bskyConn.session?.did) }).distinctBy { it.cid } 207 + (uiState.skeets + response.feed.map { SkeetData.fromFeedViewPost(it, bskyConn.session?.did, replyFilterMode) }).distinctBy { it.cid } 208 208 } 209 209 210 210 uiState = uiState.copy(