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.

ComposeView: paste images as attachments

geesawra 334a835d 6c5f7197

+50 -28
+2 -2
.idea/deploymentTargetSelector.xml
··· 4 4 <selectionStates> 5 5 <SelectionState runConfigName="app"> 6 6 <option name="selectionMode" value="DROPDOWN" /> 7 - <DropdownSelection timestamp="2025-10-01T16:38:18.485845200Z"> 7 + <DropdownSelection timestamp="2025-10-02T10:56:49.580061Z"> 8 8 <Target type="DEFAULT_BOOT"> 9 9 <handle> 10 - <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\micro\.android\avd\Medium_Phone.avd" /> 10 + <DeviceId pluginId="LocalEmulator" identifier="path=/Users/gsora/.android/avd/Medium_Phone.avd" /> 11 11 </handle> 12 12 </Target> 13 13 </DropdownSelection>
+48 -26
app/src/main/java/industries/geesawra/jerryno/ComposeView.kt
··· 7 7 import androidx.activity.compose.rememberLauncherForActivityResult 8 8 import androidx.activity.result.PickVisualMediaRequest 9 9 import androidx.activity.result.contract.ActivityResultContracts 10 + import androidx.compose.foundation.ExperimentalFoundationApi 10 11 import androidx.compose.foundation.ScrollState 11 12 import androidx.compose.foundation.background 13 + import androidx.compose.foundation.content.MediaType 14 + import androidx.compose.foundation.content.ReceiveContentListener 15 + import androidx.compose.foundation.content.consume 16 + import androidx.compose.foundation.content.contentReceiver 17 + import androidx.compose.foundation.content.hasMediaType 12 18 import androidx.compose.foundation.layout.Arrangement 13 19 import androidx.compose.foundation.layout.Box 14 20 import androidx.compose.foundation.layout.Column ··· 25 31 import androidx.compose.foundation.layout.padding 26 32 import androidx.compose.foundation.layout.size 27 33 import androidx.compose.foundation.layout.windowInsetsPadding 28 - import androidx.compose.foundation.text.KeyboardActions 34 + import androidx.compose.foundation.text.input.TextFieldLineLimits 35 + import androidx.compose.foundation.text.input.clearText 36 + import androidx.compose.foundation.text.input.rememberTextFieldState 29 37 import androidx.compose.foundation.verticalScroll 30 38 import androidx.compose.material.icons.Icons 31 39 import androidx.compose.material.icons.automirrored.filled.Send ··· 45 53 import androidx.compose.runtime.Composable 46 54 import androidx.compose.runtime.LaunchedEffect 47 55 import androidx.compose.runtime.MutableState 48 - import androidx.compose.runtime.getValue 49 56 import androidx.compose.runtime.mutableIntStateOf 50 57 import androidx.compose.runtime.mutableStateOf 51 58 import androidx.compose.runtime.remember 52 - import androidx.compose.runtime.setValue 53 59 import androidx.compose.ui.Alignment 54 60 import androidx.compose.ui.Modifier 55 61 import androidx.compose.ui.focus.FocusRequester 56 62 import androidx.compose.ui.focus.focusRequester 57 63 import androidx.compose.ui.graphics.Color 58 64 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 59 - import androidx.compose.ui.text.input.ImeAction 60 65 import androidx.compose.ui.unit.dp 61 66 import industries.geesawra.jerryno.datalayer.SkeetData 62 67 import industries.geesawra.jerryno.datalayer.TimelineViewModel 63 68 import kotlinx.coroutines.CoroutineScope 64 69 import kotlinx.coroutines.launch 65 70 66 - @OptIn(ExperimentalMaterial3Api::class) 71 + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 67 72 @Composable 68 73 fun ComposeView( 69 74 context: Context, ··· 75 80 ) { 76 81 val focusRequester = remember { FocusRequester() } 77 82 val keyboardController = LocalSoftwareKeyboardController.current 78 - var postText by remember { mutableStateOf("") } 79 83 val charCount = remember { mutableIntStateOf(0) } 80 84 val wasEdited = remember { mutableStateOf(false) } 81 85 val maxChars = 300 82 - 86 + val composeFieldState = rememberTextFieldState( 87 + "" 88 + ) 83 89 val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } 84 90 val mediaSelectedIsVideo = remember { mutableStateOf(false) } 85 91 ··· 90 96 } else { 91 97 keyboardController?.hide() 92 98 // Reset state when sheet is hidden 93 - postText = "" 99 + composeFieldState.clearText() 94 100 charCount.intValue = 0 95 101 inReplyTo.value = null 96 102 mediaSelected.value = mapOf() ··· 99 105 } 100 106 } 101 107 108 + // Remember the ReceiveContentListener object as it is created inside a Composable scope 109 + val receiveContentListener = remember { 110 + ReceiveContentListener { transferableContent -> 111 + when (transferableContent.hasMediaType(MediaType.Image)) { 112 + true -> transferableContent.consume { 113 + val uri = it.uri 114 + val mimeType: String? = context.contentResolver.getType(uri) 115 + mediaSelected.value = mapOf(Pair(uri, mimeType)) 116 + true 117 + } 118 + 119 + 120 + false -> transferableContent 121 + } 122 + } 123 + } 124 + 102 125 103 126 val pickMedia = 104 127 rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(maxItems = 4)) { uris -> ··· 182 205 } 183 206 } 184 207 185 - OutlinedTextField( 186 - keyboardActions = KeyboardActions( 187 - onDone = { 188 - this.defaultKeyboardAction(ImeAction.Done) 189 - keyboardController?.hide() 190 - } 191 - ), 192 - value = postText, 193 - onValueChange = { 208 + LaunchedEffect(composeFieldState.text) { 209 + if (composeFieldState.text.isEmpty()) { 210 + wasEdited.value = false 211 + } else { 194 212 wasEdited.value = true 195 - postText = it 196 - charCount.intValue = it.length 197 - }, 213 + charCount.intValue = composeFieldState.text.length 214 + } 215 + } 216 + 217 + OutlinedTextField( 198 218 modifier = Modifier 199 219 .fillMaxWidth() 200 - .heightIn(min = 250.dp) // Adjusted min height for text field 201 - .focusRequester(focusRequester), 220 + .heightIn(min = 250.dp) 221 + .focusRequester(focusRequester) 222 + .contentReceiver(receiveContentListener), 202 223 label = { 203 224 if (wasEdited.value) { 204 225 Text( 205 226 text = "${maxChars - charCount.intValue}", 206 - color = if (postText.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 227 + color = if (composeFieldState.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 207 228 ) 208 229 } else { 209 230 Text( ··· 211 232 ) 212 233 } 213 234 }, 214 - isError = postText.length > maxChars, 215 - maxLines = 10 235 + isError = composeFieldState.text.length > maxChars, 236 + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), 237 + state = composeFieldState 216 238 ) 217 239 218 240 ActionRow( 219 241 context, 220 242 uploadingPost, 221 243 pickMedia, 222 - postText, 244 + composeFieldState.text.toString(), 223 245 mediaSelected, 224 246 mediaSelectedIsVideo, 225 247 coroutineScope,