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

Configure Feed

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

TimelineView: gallery viewer working w/zoom

geesawra 7a09dc35 c63185c6

+115 -40
+2 -1
app/build.gradle.kts
··· 69 69 implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") 70 70 implementation(platform("androidx.compose:compose-bom:2025.09.00")) 71 71 implementation("androidx.paging:paging-compose:3.3.0-alpha05") 72 - 72 + implementation("me.saket.telephoto:zoomable:0.17.0") 73 + implementation("me.saket.telephoto:zoomable-image-coil3:0.17.0") 73 74 implementation(libs.androidx.compose.animation.core.lint) 74 75 implementation(libs.androidx.material3) 75 76 ksp("com.google.dagger:hilt-compiler:2.57.2")
+92
app/src/main/java/industries/geesawra/jerryno/GalleryViewer.kt
··· 1 + package industries.geesawra.jerryno 2 + 3 + import android.content.Context 4 + import android.widget.Toast 5 + import androidx.compose.foundation.background 6 + import androidx.compose.foundation.layout.Box 7 + import androidx.compose.foundation.layout.fillMaxSize 8 + import androidx.compose.foundation.pager.HorizontalPager 9 + import androidx.compose.foundation.pager.rememberPagerState 10 + import androidx.compose.runtime.Composable 11 + import androidx.compose.runtime.rememberCoroutineScope 12 + import androidx.compose.ui.Modifier 13 + import androidx.compose.ui.graphics.Color 14 + import androidx.compose.ui.layout.ContentScale 15 + import androidx.compose.ui.platform.LocalContext 16 + import androidx.compose.ui.window.Dialog 17 + import androidx.compose.ui.window.DialogProperties 18 + import kotlinx.coroutines.launch 19 + import me.saket.telephoto.zoomable.OverzoomEffect 20 + import me.saket.telephoto.zoomable.ZoomSpec 21 + import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage 22 + import me.saket.telephoto.zoomable.rememberZoomableImageState 23 + import me.saket.telephoto.zoomable.rememberZoomableState 24 + import okhttp3.HttpUrl 25 + import okhttp3.HttpUrl.Companion.toHttpUrl 26 + 27 + @Composable 28 + fun GalleryViewer( 29 + imageUrls: List<Image>, // Now takes a list of URLs 30 + initialPage: Int = 0, 31 + onDismiss: () -> Unit 32 + ) { 33 + val context = LocalContext.current 34 + val coroutineScope = rememberCoroutineScope() 35 + 36 + Dialog( 37 + onDismissRequest = onDismiss, 38 + properties = DialogProperties(usePlatformDefaultWidth = false) 39 + ) { 40 + Box( 41 + modifier = Modifier 42 + .fillMaxSize() 43 + .background(Color.Transparent) 44 + ) { 45 + val pagerState = rememberPagerState(initialPage = initialPage) { 46 + imageUrls.size 47 + } 48 + HorizontalPager( 49 + state = pagerState, 50 + modifier = Modifier.fillMaxSize() 51 + ) { page -> 52 + ZoomableAsyncImage( 53 + state = rememberZoomableImageState( 54 + zoomableState = rememberZoomableState( 55 + zoomSpec = ZoomSpec( 56 + maxZoomFactor = 8f, 57 + overzoomEffect = OverzoomEffect.RubberBanding, 58 + ) 59 + ) 60 + ), 61 + model = imageUrls[page].url, 62 + contentDescription = imageUrls[page].alt, 63 + modifier = Modifier 64 + .fillMaxSize(), 65 + contentScale = ContentScale.Fit, 66 + onClick = { onDismiss() }, 67 + onLongClick = { 68 + coroutineScope.launch { 69 + downloadImage(context, imageUrls[page].url.toHttpUrl()) 70 + } 71 + } 72 + ) 73 + } 74 + } 75 + } 76 + } 77 + 78 + suspend fun downloadImage(context: Context, imageUrl: HttpUrl) { 79 + Toast.makeText(context, "Don't remember to implement download!!", Toast.LENGTH_LONG).show() 80 + // 81 + // val result = context.imageLoader.execute( 82 + // ImageRequest.Builder(context) 83 + // .data(imageUrl) 84 + // .build() 85 + // ) 86 + // if (result is SuccessResult) { 87 + // val cacheKey = result.diskCacheKey ?: error("image wasn't saved to disk") 88 + // val diskCache = context.imageLoader.diskCache!! 89 + // diskCache.openSnapshot(cacheKey)!!.use { 90 + // } 91 + // } 92 + }
+1 -1
app/src/main/java/industries/geesawra/jerryno/MainActivity.kt
··· 118 118 TimelineView( 119 119 timelineViewModel = timelineViewModel, 120 120 coroutineScope = rememberCoroutineScope(), 121 - loginError = { 121 + onLoginError = { 122 122 navController.navigate(TimelineScreen.Login.name) 123 123 } 124 124 )
+18 -36
app/src/main/java/industries/geesawra/jerryno/PostImageGallery.kt
··· 1 1 package industries.geesawra.jerryno 2 2 3 + import androidx.compose.foundation.clickable 3 4 import androidx.compose.foundation.layout.Arrangement 4 5 import androidx.compose.foundation.layout.Row 5 6 import androidx.compose.foundation.layout.fillMaxWidth 6 7 import androidx.compose.foundation.shape.RoundedCornerShape 7 8 import androidx.compose.runtime.Composable 9 + import androidx.compose.runtime.mutableStateOf 10 + import androidx.compose.runtime.remember 8 11 import androidx.compose.ui.Modifier 9 12 import androidx.compose.ui.draw.clip 10 13 import androidx.compose.ui.layout.ContentScale ··· 17 20 ) 18 21 19 22 @Composable 20 - //fun PostImageGallery( 21 - // modifier: Modifier = Modifier, 22 - // images: List<Image>, 23 - //) { 24 - // val columns = when (images.size) { 25 - // 1 -> GridCells.Fixed(1) // One image gets one full-width column 26 - // else -> GridCells.Fixed(2) // Two or more images get two columns 27 - // } 28 - // 29 - // LazyVerticalGrid( 30 - // horizontalArrangement = Arrangement.spacedBy(8.dp), 31 - // verticalArrangement = Arrangement.spacedBy(8.dp), 32 - // modifier = modifier, 33 - // columns = columns, 34 - // userScrollEnabled = false, 35 - // content = { 36 - // items(images.size) { index -> 37 - // val img = images[index] 38 - // 39 - // AsyncImage( 40 - // model = ImageRequest.Builder(LocalContext.current) 41 - // .data(img.url) 42 - // .crossfade(true) 43 - // .build(), 44 - // contentScale = ContentScale.Crop, 45 - // contentDescription = img.alt, 46 - // modifier = Modifier 47 - // .fillMaxWidth() 48 - // .heightIn(max = 75.dp) 49 - // .clip(RoundedCornerShape(12.dp)) 50 - // ) 51 - // } 52 - // } 53 - // ) 54 - //} 55 23 fun PostImageGallery( 56 24 modifier: Modifier = Modifier, 57 25 images: List<Image>, 58 26 ) { 27 + val galleryVisible = remember { mutableStateOf<Int?>(null) } 28 + 29 + galleryVisible.value?.let { 30 + GalleryViewer( 31 + imageUrls = images, 32 + initialPage = it 33 + ) { 34 + galleryVisible.value = null 35 + } 36 + } 37 + 59 38 Row( 60 39 modifier = modifier.fillMaxWidth(), 61 40 // This automatically adds 4.dp of space between each image 62 41 horizontalArrangement = Arrangement.spacedBy(8.dp) 63 42 ) { 64 43 // We take the first 4 images and give them each a weight 65 - images.take(4).forEach { image -> 44 + images.take(4).forEachIndexed { idx, image -> 66 45 AsyncImage( 67 46 model = image.url, 68 47 contentDescription = image.alt, 69 48 contentScale = ContentScale.Crop, // Fills the space 70 49 modifier = Modifier 50 + .clickable { 51 + galleryVisible.value = idx 52 + } 71 53 // 1. Give each image an equal share of the width 72 54 .weight(1f) 73 55 // 3. Apply rounded corners
+2 -2
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 103 103 fun TimelineView( 104 104 timelineViewModel: TimelineViewModel, 105 105 coroutineScope: CoroutineScope, 106 - loginError: () -> Unit, 106 + onLoginError: () -> Unit, 107 107 ) { 108 108 val scaffoldState = rememberBottomSheetScaffoldState( 109 109 bottomSheetState = rememberModalBottomSheetState( ··· 321 321 scaffoldState.bottomSheetState.expand() 322 322 } 323 323 }, 324 - loginError = loginError 324 + loginError = onLoginError 325 325 ) 326 326 } 327 327 )