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 animations and haptic feedback across the app

Like button: scale bounce animation (spring), crossfade icon, red (error) color when active
Repost button: scale bounce animation, crossfade icon, tertiary color when active
Repost long-press: heavy haptic confirms quote-post gesture registered
All action buttons (share, reply, like, repost): light haptic on tap
Follow/unfollow: confirmation haptic
Tab switching: light haptic
Scroll-to-top FABs: light haptic
Compose FAB: light haptic
Send post button: confirmation haptic
Content warning reveal: heavy haptic for deliberate action

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

geesawra 52974038 4aca9d18

+105 -11
+4
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 68 68 import androidx.compose.material3.TextButton 69 69 import androidx.compose.runtime.Composable 70 70 import androidx.compose.runtime.getValue 71 + import androidx.compose.ui.hapticfeedback.HapticFeedbackType 72 + import androidx.compose.ui.platform.LocalHapticFeedback 71 73 import androidx.compose.runtime.setValue 72 74 import androidx.compose.runtime.LaunchedEffect 73 75 import androidx.compose.runtime.MutableState ··· 763 765 (postText.isNotBlank() || mediaSelected.value.isNotEmpty()) && postText.length <= maxChars 764 766 } 765 767 768 + val haptic = LocalHapticFeedback.current 766 769 if (uploadingPost.value) { 767 770 CircularWavyProgressIndicator() 768 771 } else { 769 772 Button( 770 773 onClick = { 774 + haptic.performHapticFeedback(HapticFeedbackType.Confirm) 771 775 coroutineScope.launch { 772 776 uploadingPost.value = true // Show progress immediately 773 777 timelineViewModel.post(
+13 -2
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 3 3 package industries.geesawra.monarch 4 4 5 5 import android.widget.Toast 6 + import androidx.compose.ui.hapticfeedback.HapticFeedbackType 7 + import androidx.compose.ui.platform.LocalHapticFeedback 6 8 import androidx.activity.compose.BackHandler 7 9 import androidx.compose.foundation.ExperimentalFoundationApi 8 10 import androidx.compose.foundation.combinedClickable ··· 256 258 timelineViewModel.uiState.isFetchingMoreTimeline || timelineViewModel.uiState.isFetchingMoreNotifications 257 259 val isScrollEnabled = true 258 260 val ctx = LocalContext.current 261 + val haptic = LocalHapticFeedback.current 259 262 260 263 261 264 LaunchedEffect(timelineViewModel.uiState.loginError) { ··· 499 502 modifier = Modifier 500 503 .size(40.dp), 501 504 onClick = { 505 + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) 502 506 coroutineScope.launch { 503 507 launch { 504 508 if (timelineState.firstVisibleItemIndex > 8) { ··· 526 530 } 527 531 528 532 FloatingActionButton( 529 - onClick = fobOnClick 533 + onClick = { 534 + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) 535 + fobOnClick() 536 + } 530 537 ) { 531 538 Icon(Icons.Filled.Create, "Post") 532 539 } ··· 545 552 modifier = Modifier 546 553 .size(40.dp), 547 554 onClick = { 555 + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) 548 556 coroutineScope.launch { 549 557 launch { 550 558 if (notificationsState.firstVisibleItemIndex > 8) { ··· 612 620 }, 613 621 label = { Text(stringResource(it.label)) }, 614 622 selected = it == currentDestination, 615 - onClick = { currentDestination = it } 623 + onClick = { 624 + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) 625 + currentDestination = it 626 + } 616 627 ) 617 628 } 618 629 }
+11 -2
app/src/main/java/industries/geesawra/monarch/ProfileView.kt
··· 93 93 import app.bsky.feed.GetAuthorFeedFilter 94 94 import androidx.compose.material3.PrimaryTabRow 95 95 import androidx.compose.material3.Tab 96 + import androidx.compose.ui.hapticfeedback.HapticFeedbackType 97 + import androidx.compose.ui.platform.LocalHapticFeedback 96 98 import coil3.compose.AsyncImage 97 99 import coil3.request.ImageRequest 98 100 import coil3.request.crossfade ··· 509 511 timelineViewModel: TimelineViewModel, 510 512 ) { 511 513 val isFollowing = profile.viewer?.following != null 514 + val haptic = LocalHapticFeedback.current 512 515 513 516 if (isFollowing) { 514 517 FilledTonalButton( 515 - onClick = { timelineViewModel.unfollowProfile() }, 518 + onClick = { 519 + haptic.performHapticFeedback(HapticFeedbackType.Confirm) 520 + timelineViewModel.unfollowProfile() 521 + }, 516 522 ) { 517 523 Icon( 518 524 Icons.Default.PersonRemove, ··· 524 530 } 525 531 } else { 526 532 Button( 527 - onClick = { timelineViewModel.followProfile() }, 533 + onClick = { 534 + haptic.performHapticFeedback(HapticFeedbackType.Confirm) 535 + timelineViewModel.followProfile() 536 + }, 528 537 ) { 529 538 Icon( 530 539 Icons.Default.PersonAdd,
+7 -1
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 78 78 import app.bsky.feed.PostViewEmbedUnion 79 79 import app.bsky.feed.ReplyRefParentUnion 80 80 import androidx.compose.ui.graphics.painter.ColorPainter 81 + import androidx.compose.ui.hapticfeedback.HapticFeedbackType 82 + import androidx.compose.ui.platform.LocalHapticFeedback 81 83 import coil3.compose.AsyncImage 82 84 import coil3.request.ImageRequest 83 85 import coil3.request.crossfade ··· 982 984 style = MaterialTheme.typography.titleSmall, 983 985 modifier = Modifier.weight(1f), 984 986 ) 985 - TextButton(onClick = onShow) { 987 + val haptic = LocalHapticFeedback.current 988 + TextButton(onClick = { 989 + haptic.performHapticFeedback(HapticFeedbackType.LongPress) 990 + onShow() 991 + }) { 986 992 Text("Show") 987 993 } 988 994 }
+70 -6
app/src/main/java/industries/geesawra/monarch/TimelinePostActionsView.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 3 import android.content.Intent 4 + import androidx.compose.animation.AnimatedContent 5 + import androidx.compose.animation.animateColorAsState 6 + import androidx.compose.animation.core.Spring 7 + import androidx.compose.animation.core.animateFloatAsState 8 + import androidx.compose.animation.core.spring 9 + import androidx.compose.animation.fadeIn 10 + import androidx.compose.animation.fadeOut 11 + import androidx.compose.animation.togetherWith 4 12 import androidx.compose.foundation.interaction.MutableInteractionSource 5 13 import androidx.compose.foundation.interaction.collectIsPressedAsState 6 14 import androidx.compose.foundation.layout.Arrangement ··· 33 41 import androidx.compose.ui.Alignment 34 42 import androidx.compose.ui.Modifier 35 43 import androidx.compose.ui.draw.clip 44 + import androidx.compose.ui.draw.scale 36 45 import androidx.compose.ui.graphics.Color 37 - import androidx.compose.ui.text.font.FontWeight 38 46 import androidx.compose.ui.graphics.vector.ImageVector 47 + import androidx.compose.ui.hapticfeedback.HapticFeedbackType 39 48 import androidx.compose.ui.platform.LocalContext 49 + import androidx.compose.ui.platform.LocalHapticFeedback 50 + import androidx.compose.ui.text.font.FontWeight 40 51 import androidx.compose.ui.unit.dp 41 52 import androidx.core.net.toUri 42 53 import industries.geesawra.monarch.datalayer.SkeetData ··· 53 64 imageVector: ImageVector, 54 65 contentDescription: String, 55 66 number: MutableLongState, 56 - tint: Color 67 + tint: Color, 68 + scale: Float = 1f, 57 69 ) { 58 70 Row( 59 71 horizontalArrangement = Arrangement.Center, ··· 62 74 Icon( 63 75 imageVector, 64 76 contentDescription = contentDescription, 65 - modifier = Modifier.size(18.dp), 77 + modifier = Modifier 78 + .size(18.dp) 79 + .scale(scale), 66 80 tint = tint 67 81 ) 68 82 Text( ··· 99 113 val likes = remember { mutableLongStateOf(skeet.likes ?: 0) } 100 114 val reposts = remember { mutableLongStateOf(skeet.reposts ?: 0) } 101 115 val replies = remember { mutableLongStateOf(skeet.replies ?: 0) } 116 + val haptic = LocalHapticFeedback.current 102 117 103 118 Row( 104 119 horizontalArrangement = Arrangement.spacedBy(4.dp), ··· 113 128 114 129 IconButton( 115 130 onClick = { 131 + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) 116 132 val sendIntent: Intent = Intent().apply { 117 133 action = Intent.ACTION_SEND 118 134 type = "text/plain" ··· 133 149 134 150 IconButton( 135 151 onClick = { 152 + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) 136 153 onReplyTap(skeet, false) 137 154 } 138 155 ) { ··· 157 174 } 158 175 159 176 var isLiked by rememberSaveable { mutableStateOf(skeet.didLike) } 177 + var likeBounce by remember { mutableStateOf(false) } 178 + val likeScale by animateFloatAsState( 179 + targetValue = if (likeBounce) 1.3f else 1f, 180 + animationSpec = spring( 181 + dampingRatio = Spring.DampingRatioMediumBouncy, 182 + stiffness = Spring.StiffnessMedium 183 + ), 184 + label = "likeScale" 185 + ) 186 + LaunchedEffect(likeBounce) { 187 + if (likeBounce) { 188 + delay(150) 189 + likeBounce = false 190 + } 191 + } 192 + val likeColor by animateColorAsState( 193 + targetValue = if (isLiked) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, 194 + label = "likeColor" 195 + ) 196 + 160 197 IconButton( 161 198 onClick = { 199 + likeBounce = true 200 + haptic.performHapticFeedback(HapticFeedbackType.Confirm) 162 201 when (isLiked) { 163 202 false -> timelineViewModel?.like(skeet.uri, skeet.cid) { 164 203 isLiked = true ··· 176 215 if (isLiked) HeartFilled else Heart, 177 216 contentDescription = "Like", 178 217 number = likes, 179 - tint = if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 218 + tint = likeColor, 219 + scale = likeScale, 180 220 ) 181 221 } 182 222 183 223 var isReposted by rememberSaveable { mutableStateOf(skeet.didRepost) } 224 + var repostBounce by remember { mutableStateOf(false) } 225 + val repostScale by animateFloatAsState( 226 + targetValue = if (repostBounce) 1.3f else 1f, 227 + animationSpec = spring( 228 + dampingRatio = Spring.DampingRatioMediumBouncy, 229 + stiffness = Spring.StiffnessMedium 230 + ), 231 + label = "repostScale" 232 + ) 233 + LaunchedEffect(repostBounce) { 234 + if (repostBounce) { 235 + delay(150) 236 + repostBounce = false 237 + } 238 + } 239 + val repostColor by animateColorAsState( 240 + targetValue = if (isReposted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant, 241 + label = "repostColor" 242 + ) 243 + 184 244 LongPressIconButton( 185 245 onClick = { 246 + repostBounce = true 247 + haptic.performHapticFeedback(HapticFeedbackType.Confirm) 186 248 when (isReposted) { 187 249 false -> timelineViewModel?.repost(skeet.uri, skeet.cid) { 188 250 isReposted = true ··· 196 258 } 197 259 }, 198 260 onLongClick = { 261 + haptic.performHapticFeedback(HapticFeedbackType.LongPress) 199 262 onReplyTap(skeet, true) 200 263 } 201 264 ) { ··· 203 266 if (isReposted) Icons.Default.RepeatOn else Icons.Default.Repeat, 204 267 contentDescription = "Repost", 205 268 number = reposts, 206 - if (isReposted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 269 + tint = repostColor, 270 + scale = repostScale, 207 271 ) 208 272 } 209 273 } ··· 239 303 ) { 240 304 content() 241 305 } 242 - } 306 + }