A cheap attempt at a native Bluesky client for Android

ComposeView: allow deleting media before posting

+133 -104
+25 -47
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 63 63 import androidx.compose.ui.graphics.Color 64 64 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 65 65 import androidx.compose.ui.unit.dp 66 - import androidx.media3.common.MimeTypes 67 66 import com.atproto.repo.StrongRef 68 67 import industries.geesawra.monarch.datalayer.SkeetData 69 68 import industries.geesawra.monarch.datalayer.TimelineViewModel 70 - import io.sanghun.compose.video.RepeatMode 71 - import io.sanghun.compose.video.VideoPlayer 72 - import io.sanghun.compose.video.controller.VideoPlayerControllerConfig 73 - import io.sanghun.compose.video.uri.VideoPlayerMediaItem 74 69 import kotlinx.coroutines.CoroutineScope 75 70 import kotlinx.coroutines.launch 76 71 ··· 93 88 val composeFieldState = rememberTextFieldState( 94 89 "" 95 90 ) 96 - val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } 91 + val mediaSelected = remember { mutableStateOf(listOf<Uri>()) } 97 92 val mediaSelectedIsVideo = remember { mutableStateOf(false) } 98 93 99 94 LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { ··· 107 102 charCount.intValue = 0 108 103 inReplyTo.value = null 109 104 isQuotePost.value = false 110 - mediaSelected.value = mapOf() 105 + mediaSelected.value = listOf() 111 106 mediaSelectedIsVideo.value = false 112 107 113 108 } ··· 120 115 true -> transferableContent.consume { 121 116 val uri = it.uri 122 117 val mimeType: String? = context.contentResolver.getType(uri) 123 - mediaSelected.value = mapOf(Pair(uri, mimeType)) 118 + mediaSelected.value = listOf(uri) 124 119 true 125 120 } 126 121 ··· 169 164 } 170 165 171 166 mediaSelectedIsVideo.value = containsVideo && urisMap.size == 1 172 - mediaSelected.value = urisMap.filterValues { it != null } 167 + mediaSelected.value = urisMap.filterValues { it != null }.keys.toList() 173 168 } 174 169 175 170 val uploadingPost = remember { mutableStateOf(false) } ··· 271 266 when (mediaSelectedIsVideo.value) { 272 267 false -> PostImageGallery( 273 268 modifier = Modifier 274 - .fillMaxWidth() // Gallery should fill card width 269 + .fillMaxWidth() 275 270 .padding(8.dp), 276 - images = mediaSelected.value.keys.map { uri -> 271 + images = mediaSelected.value.map { uri -> 277 272 Image(url = uri.toString(), alt = "Selected media") 278 273 }, 274 + onCrossClick = { 275 + val toDelUri = mediaSelected.value[it] 276 + mediaSelected.value = 277 + mediaSelected.value.filter { uri -> 278 + uri != toDelUri 279 + } 280 + } 279 281 ) 280 282 281 - true -> VideoPlayer( 282 - mediaItems = listOf( 283 - VideoPlayerMediaItem.NetworkMediaItem( 284 - url = mediaSelected.value.keys.first().toString(), 285 - mimeType = MimeTypes.APPLICATION_M3U8, 286 - ) 287 - ), 288 - handleLifecycle = false, 289 - autoPlay = false, 290 - usePlayerController = true, 291 - enablePip = false, 292 - handleAudioFocus = true, 293 - controllerConfig = VideoPlayerControllerConfig( 294 - showSpeedAndPitchOverlay = false, 295 - showSubtitleButton = false, 296 - showCurrentTimeAndTotalTime = true, 297 - showBufferingProgress = false, 298 - showForwardIncrementButton = true, 299 - showBackwardIncrementButton = true, 300 - showBackTrackButton = false, 301 - showNextTrackButton = false, 302 - showRepeatModeButton = true, 303 - controllerShowTimeMilliSeconds = 5_000, 304 - controllerAutoShow = true, 305 - showFullScreenButton = true, 306 - ), 307 - volume = 0.5f, // volume 0.0f to 1.0f 308 - repeatMode = RepeatMode.NONE, // or RepeatMode.ALL, RepeatMode.ONE 309 - modifier = Modifier 310 - .fillMaxSize() 311 - .heightIn(max = 500.dp) 312 - .padding(8.dp), 313 - ) 283 + true -> DeletableMediaView( 284 + originalIndex = 0, 285 + onCrossClick = { 286 + mediaSelected.value = listOf() 287 + }, 288 + onMediaClick = { } 289 + ) { 290 + VideoView(uri = mediaSelected.value.first()) 291 + } 314 292 } 315 293 } 316 294 } ··· 329 307 uploadingPost: MutableState<Boolean>, 330 308 pickMedia: ManagedActivityResultLauncher<PickVisualMediaRequest, List<@JvmSuppressWildcards Uri>>, 331 309 postText: String, 332 - mediaSelected: MutableState<Map<Uri, String?>>, 310 + mediaSelected: MutableState<List<Uri>>, 333 311 mediaSelectedIsVideo: MutableState<Boolean>, 334 312 coroutineScope: CoroutineScope, 335 313 maxChars: Int, ··· 371 349 uploadingPost.value = true // Show progress immediately 372 350 timelineViewModel.post( 373 351 content = postText, 374 - images = if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 352 + images = if (!mediaSelectedIsVideo.value) mediaSelected.value 375 353 .ifEmpty { null } else null, 376 - video = if (mediaSelectedIsVideo.value) mediaSelected.value.keys.firstOrNull() else null, 354 + video = if (mediaSelectedIsVideo.value) mediaSelected.value.firstOrNull() else null, 377 355 replyRef = if (!isQuotePost) { 378 356 inReplyToData?.replyRef() 379 357 } else {
+107 -56
app/src/main/java/industries/geesawra/monarch/PostImageGallery.kt
··· 2 2 3 3 import androidx.compose.foundation.clickable 4 4 import androidx.compose.foundation.layout.Arrangement 5 + import androidx.compose.foundation.layout.Box 5 6 import androidx.compose.foundation.layout.Column 6 - import androidx.compose.foundation.layout.IntrinsicSize // Added import 7 + import androidx.compose.foundation.layout.IntrinsicSize 7 8 import androidx.compose.foundation.layout.Row 8 - import androidx.compose.foundation.layout.RowScope 9 9 import androidx.compose.foundation.layout.Spacer 10 10 import androidx.compose.foundation.layout.aspectRatio 11 - import androidx.compose.foundation.layout.fillMaxHeight // Added import 11 + import androidx.compose.foundation.layout.fillMaxHeight 12 12 import androidx.compose.foundation.layout.fillMaxWidth 13 - import androidx.compose.foundation.layout.height // Added import 13 + import androidx.compose.foundation.layout.height 14 + import androidx.compose.foundation.layout.padding 14 15 import androidx.compose.foundation.shape.RoundedCornerShape 16 + import androidx.compose.material.icons.Icons 17 + import androidx.compose.material.icons.filled.Close 18 + import androidx.compose.material3.Button 19 + import androidx.compose.material3.Icon 15 20 import androidx.compose.runtime.Composable 16 21 import androidx.compose.runtime.mutableStateOf 17 22 import androidx.compose.runtime.remember ··· 33 38 fun PostImageGallery( 34 39 modifier: Modifier = Modifier, 35 40 images: List<Image>, 41 + onCrossClick: ((Int) -> Unit)? = null 36 42 ) { 37 43 val galleryVisible = remember { mutableStateOf<Int?>(null) } 38 44 ··· 60 66 modifier = modifier 61 67 .fillMaxWidth() 62 68 ) { 63 - AsyncImage( 64 - model = ImageRequest.Builder(LocalContext.current) 65 - .data(imagesToDisplay[0].url) 66 - .crossfade(true) 67 - .build(), 68 - contentDescription = imagesToDisplay[0].alt, 69 - contentScale = ContentScale.Crop, 70 - modifier = Modifier 71 - .fillMaxWidth() 72 - .aspectRatio(1f) // Added aspect ratio for defined height 73 - .clip(RoundedCornerShape(12.dp)) 74 - .clickable { galleryVisible.value = 0 } // Index in original list 75 - ) 69 + DeletableImageView( 70 + modifier = Modifier.weight(1f), 71 + image = imagesToDisplay[0], 72 + originalIndex = 0, 73 + onCrossClick = onCrossClick, 74 + onMediaClick = { galleryVisible.value = 0 }) 76 75 } 77 76 } 78 77 ··· 81 80 modifier = modifier.fillMaxWidth(), 82 81 horizontalArrangement = Arrangement.spacedBy(8.dp) 83 82 ) { 84 - GalleryImageCell( 83 + DeletableImageView( 84 + modifier = Modifier.weight(1f), 85 85 image = imagesToDisplay[0], 86 86 originalIndex = 0, 87 - onImageClick = { galleryVisible.value = it }) 88 - GalleryImageCell( 87 + onCrossClick = onCrossClick, 88 + onMediaClick = { galleryVisible.value = it }) 89 + DeletableImageView( 90 + modifier = Modifier.weight(1f), 91 + 89 92 image = imagesToDisplay[1], 90 93 originalIndex = 1, 91 - onImageClick = { galleryVisible.value = it }) 94 + onCrossClick = onCrossClick, 95 + onMediaClick = { galleryVisible.value = it }) 92 96 } 93 97 } 94 98 ··· 101 105 modifier = Modifier.fillMaxWidth(), 102 106 horizontalArrangement = Arrangement.spacedBy(8.dp) 103 107 ) { 104 - GalleryImageCell( 108 + DeletableImageView( 109 + modifier = Modifier.weight(1f), 105 110 image = imagesToDisplay[0], 106 111 originalIndex = 0, 107 - onImageClick = { galleryVisible.value = it }) 108 - GalleryImageCell( 112 + onCrossClick = onCrossClick, 113 + onMediaClick = { galleryVisible.value = it }) 114 + DeletableImageView( 115 + modifier = Modifier.weight(1f), 109 116 image = imagesToDisplay[1], 110 117 originalIndex = 1, 111 - onImageClick = { galleryVisible.value = it }) 118 + onCrossClick = onCrossClick, 119 + onMediaClick = { galleryVisible.value = it }) 112 120 } 113 121 Row( 114 122 modifier = Modifier ··· 116 124 .height(IntrinsicSize.Min), // Apply IntrinsicSize.Min to the Row 117 125 horizontalArrangement = Arrangement.spacedBy(8.dp) 118 126 ) { 119 - GalleryImageCell( 127 + DeletableImageView( 128 + modifier = Modifier.weight(1f), 120 129 image = imagesToDisplay[2], 121 130 originalIndex = 2, 122 - onImageClick = { galleryVisible.value = it }) 131 + onCrossClick = onCrossClick, 132 + onMediaClick = { galleryVisible.value = it }) 123 133 Spacer( 124 134 Modifier 125 135 .weight(1f) ··· 138 148 modifier = Modifier.fillMaxWidth(), 139 149 horizontalArrangement = Arrangement.spacedBy(8.dp) 140 150 ) { 141 - GalleryImageCell( 151 + DeletableImageView( 152 + modifier = Modifier.weight(1f), 142 153 image = imagesToDisplay[0], 143 154 originalIndex = 0, 144 - onImageClick = { galleryVisible.value = it }) 145 - GalleryImageCell( 155 + onCrossClick = onCrossClick, 156 + onMediaClick = { galleryVisible.value = it }) 157 + DeletableImageView( 158 + modifier = Modifier.weight(1f), 146 159 image = imagesToDisplay[1], 147 160 originalIndex = 1, 148 - onImageClick = { galleryVisible.value = it }) 161 + onCrossClick = onCrossClick, 162 + onMediaClick = { galleryVisible.value = it }) 149 163 } 150 164 Row( 151 165 modifier = Modifier.fillMaxWidth(), 152 166 horizontalArrangement = Arrangement.spacedBy(8.dp) 153 167 ) { 154 - GalleryImageCell( 168 + DeletableImageView( 169 + modifier = Modifier.weight(1f), 155 170 image = imagesToDisplay[2], 156 171 originalIndex = 2, 157 - onImageClick = { galleryVisible.value = it }) 158 - GalleryImageCell( 172 + onCrossClick = onCrossClick, 173 + onMediaClick = { galleryVisible.value = it }) 174 + DeletableImageView( 175 + modifier = Modifier.weight(1f), 159 176 image = imagesToDisplay[3], 160 177 originalIndex = 3, 161 - onImageClick = { galleryVisible.value = it }) 178 + onCrossClick = onCrossClick, 179 + onMediaClick = { galleryVisible.value = it }) 162 180 } 163 181 } 164 182 } ··· 166 184 } 167 185 168 186 @Composable 169 - private fun RowScope.GalleryImageCell( 187 + private fun DeletableImageView( 188 + modifier: Modifier = Modifier, 170 189 image: Image, 171 - originalIndex: Int, // Index in the original `images` list 172 - onImageClick: (Int) -> Unit 190 + originalIndex: Int, 191 + onCrossClick: ((Int) -> Unit)? = null, 192 + onMediaClick: (Int) -> Unit, 193 + ) { 194 + DeletableMediaView( 195 + modifier = modifier, 196 + originalIndex = originalIndex, 197 + onCrossClick = onCrossClick, 198 + onMediaClick = onMediaClick, 199 + ) { 200 + AsyncImage( 201 + model = ImageRequest.Builder(LocalContext.current) 202 + .data(image.url) 203 + .crossfade(true) 204 + .build(), 205 + contentDescription = image.alt, 206 + contentScale = ContentScale.Crop, 207 + modifier = Modifier 208 + .aspectRatio(1f) // Changed from fillMaxSize() to make it square 209 + .clip(RoundedCornerShape(12.dp)) 210 + .clickable { onMediaClick(originalIndex) } 211 + ) 212 + } 213 + } 214 + 215 + @Composable 216 + fun DeletableMediaView( 217 + modifier: Modifier = Modifier, 218 + originalIndex: Int, 219 + onCrossClick: ((Int) -> Unit)? = null, 220 + onMediaClick: (Int) -> Unit, 221 + mediaView: @Composable () -> Unit, 173 222 ) { 174 - AsyncImage( 175 - model = ImageRequest.Builder(LocalContext.current) 176 - .data(image.url) 177 - .crossfade(true) 178 - .build(), 179 - contentDescription = image.alt, 180 - contentScale = ContentScale.Crop, 181 - modifier = Modifier 182 - .weight(1f) 223 + Box( 224 + modifier = modifier 183 225 .aspectRatio(1f) // Changed from fillMaxSize() to make it square 184 226 .clip(RoundedCornerShape(12.dp)) 185 - .clickable { onImageClick(originalIndex) } 186 - ) 187 - } 227 + .clickable { onMediaClick(originalIndex) } 228 + ) { 188 229 189 - // Placeholder for GalleryViewer - ensure it's defined elsewhere 190 - /* 191 - @Composable 192 - fun GalleryViewer(imageUrls: List<Image>, initialPage: Int, onDismiss: () -> Unit) { 193 - // ... your GalleryViewer implementation ... 230 + mediaView() 231 + 232 + if (onCrossClick != null) { 233 + Button( 234 + modifier = Modifier.padding(start = 8.dp, top = 4.dp), 235 + onClick = { 236 + onCrossClick(originalIndex) 237 + } 238 + ) { 239 + Icon( 240 + imageVector = Icons.Default.Close, 241 + contentDescription = "Remove embed", 242 + ) 243 + } 244 + } 245 + } 194 246 } 195 - */
+1 -1
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 220 220 } 221 221 222 222 @Composable 223 - private fun VideoView(uri: Uri) { 223 + fun VideoView(uri: Uri) { 224 224 Card( 225 225 modifier = Modifier 226 226 .heightIn(max = 500.dp)