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.

*: redesigned compose view

Need to put it in a separate file tho

geesawra 88a3cc2e c266b413

+219 -171
-143
app/src/main/java/industries/geesawra/jerryno/ComposeView.kt
··· 1 1 package industries.geesawra.jerryno 2 2 3 - import android.util.Log 4 - import androidx.activity.compose.rememberLauncherForActivityResult 5 - import androidx.activity.result.PickVisualMediaRequest 6 - import androidx.activity.result.contract.ActivityResultContracts 7 - import androidx.compose.foundation.layout.Arrangement 8 - import androidx.compose.foundation.layout.Column 9 - import androidx.compose.foundation.layout.Row 10 - import androidx.compose.foundation.layout.WindowInsets 11 - import androidx.compose.foundation.layout.fillMaxWidth 12 - import androidx.compose.foundation.layout.height 13 - import androidx.compose.foundation.layout.ime 14 - import androidx.compose.foundation.layout.imePadding 15 - import androidx.compose.foundation.layout.padding 16 - import androidx.compose.material.icons.Icons 17 - import androidx.compose.material.icons.automirrored.filled.Send 18 - import androidx.compose.material.icons.filled.Add 19 - import androidx.compose.material3.Button 20 3 import androidx.compose.material3.ExperimentalMaterial3Api 21 - import androidx.compose.material3.Icon 22 - import androidx.compose.material3.MaterialTheme 23 - import androidx.compose.material3.ModalBottomSheet 24 4 import androidx.compose.material3.SheetState 25 - import androidx.compose.material3.Text 26 - import androidx.compose.material3.TextField 27 5 import androidx.compose.runtime.Composable 28 - import androidx.compose.runtime.LaunchedEffect 29 - import androidx.compose.runtime.getValue 30 - import androidx.compose.runtime.mutableStateOf 31 - import androidx.compose.runtime.saveable.rememberSaveable 32 - import androidx.compose.runtime.setValue 33 - import androidx.compose.ui.Alignment 34 - import androidx.compose.ui.Modifier 35 6 import androidx.compose.ui.focus.FocusRequester 36 - import androidx.compose.ui.focus.focusRequester 37 - import androidx.compose.ui.text.font.FontWeight 38 - import androidx.compose.ui.unit.dp 39 7 import industries.geesawra.jerryno.datalayer.TimelineViewModel 40 8 41 9 @OptIn(ExperimentalMaterial3Api::class) ··· 46 14 timelineViewModel: TimelineViewModel, 47 15 onDismissRequest: () -> Unit, 48 16 ) { 49 - var text by rememberSaveable { mutableStateOf("") } 50 - 51 - ModalBottomSheet( 52 - onDismissRequest = { 53 - onDismissRequest() 54 - text = "" 55 - }, 56 - sheetState = modalSheetState, 57 - modifier = Modifier.imePadding(), 58 - contentWindowInsets = { 59 - WindowInsets.ime 60 - } 61 - ) { 62 - // TODO: how do we hide the keyboard when modalbottomsheet is gone? 63 - LaunchedEffect(Unit) { 64 - focusRequester.requestFocus() 65 - } 66 - Column( 67 - Modifier 68 - .fillMaxWidth() // Changed from fillMaxSize() 69 - .imePadding() 70 - ) { 71 - 72 - TextField( 73 - value = text, 74 - onValueChange = { text = it }, 75 - modifier = Modifier 76 - .focusRequester( 77 - focusRequester 78 - ) 79 - .fillMaxWidth() 80 - .height(200.dp) 81 - .padding(4.dp) 82 - ) 83 - 84 - Row( 85 - modifier = Modifier.fillMaxWidth(), // Changed from fillMaxSize() 86 - horizontalArrangement = Arrangement.SpaceBetween 87 - ) { 88 - val maxChars = 300 89 - 90 - Row( 91 - horizontalArrangement = Arrangement.Start, 92 - verticalAlignment = Alignment.CenterVertically 93 - ) { 94 - Button( 95 - enabled = text.isNotBlank() && text.length <= maxChars, 96 - onClick = { 97 - timelineViewModel.post(text) { 98 - modalSheetState.hide() 99 - } 100 - }, 101 - modifier = Modifier.padding(4.dp) 102 - ) { 103 - Icon( 104 - Icons.AutoMirrored.Default.Send, 105 - "Send" 106 - ) 107 - } 108 - 109 - val charColor = if (text.length > maxChars) { 110 - MaterialTheme.colorScheme.error 111 - } else { 112 - MaterialTheme.colorScheme.onSurface 113 - } 114 - Text( 115 - style = MaterialTheme.typography.bodyMedium, 116 - text = "${text.length}", 117 - modifier = Modifier.padding(4.dp), 118 - fontWeight = FontWeight.Bold, 119 - color = charColor 120 - ) 121 - } 122 - 123 - val pickMultipleMedia = 124 - rememberLauncherForActivityResult( 125 - ActivityResultContracts.PickMultipleVisualMedia( 126 - 5 127 - ) 128 - ) { uris -> 129 - // Callback is invoked after the user selects media items or closes the 130 - // photo picker. 131 - if (uris.isNotEmpty()) { 132 - Log.d( 133 - "PhotoPicker", 134 - "Number of items selected: ${uris.size}" 135 - ) 136 - } else { 137 - Log.d( 138 - "PhotoPicker", 139 - "No media selected" 140 - ) 141 - } 142 - } 143 - 144 - Button( 145 - onClick = { 146 - pickMultipleMedia.launch( 147 - PickVisualMediaRequest( 148 - ActivityResultContracts.PickVisualMedia.ImageAndVideo 149 - ) 150 - ) 151 - }, 152 - modifier = Modifier.padding(4.dp) 153 - ) { 154 - Icon(Icons.Default.Add, "Add") 155 - Text("Add media") 156 - } 157 - } 158 - } 159 - } 160 17 161 18 }
-1
app/src/main/java/industries/geesawra/jerryno/ShowSkeets.kt
··· 32 32 } 33 33 } 34 34 35 - 36 35 LazyColumn( 37 36 state = state, 38 37 modifier = modifier.fillMaxSize(),
+207 -24
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 1 1 package industries.geesawra.jerryno 2 2 3 + // import androidx.compose.foundation.layout.height // Will be removed for the sheet content Box 4 + import android.widget.Toast 5 + import androidx.activity.compose.rememberLauncherForActivityResult 6 + import androidx.activity.result.PickVisualMediaRequest 7 + import androidx.activity.result.contract.ActivityResultContracts 3 8 import androidx.annotation.StringRes 4 9 import androidx.compose.foundation.clickable 10 + import androidx.compose.foundation.layout.Arrangement 11 + import androidx.compose.foundation.layout.Box 12 + import androidx.compose.foundation.layout.Column 13 + import androidx.compose.foundation.layout.Row 14 + import androidx.compose.foundation.layout.Spacer 15 + import androidx.compose.foundation.layout.WindowInsets 16 + import androidx.compose.foundation.layout.consumeWindowInsets 5 17 import androidx.compose.foundation.layout.fillMaxSize 18 + import androidx.compose.foundation.layout.fillMaxWidth 19 + import androidx.compose.foundation.layout.height 20 + import androidx.compose.foundation.layout.ime 6 21 import androidx.compose.foundation.layout.padding 22 + import androidx.compose.foundation.layout.size 23 + import androidx.compose.foundation.layout.windowInsetsPadding 7 24 import androidx.compose.foundation.lazy.rememberLazyListState 25 + import androidx.compose.foundation.text.KeyboardActions 8 26 import androidx.compose.material.icons.Icons 27 + import androidx.compose.material.icons.automirrored.filled.Send 28 + import androidx.compose.material.icons.filled.Attachment 9 29 import androidx.compose.material.icons.filled.Create 10 30 import androidx.compose.material.icons.filled.Home 11 31 import androidx.compose.material.icons.filled.Notifications 32 + import androidx.compose.material3.BottomSheetScaffold 33 + import androidx.compose.material3.Button 34 + import androidx.compose.material3.ButtonDefaults 12 35 import androidx.compose.material3.ExperimentalMaterial3Api 13 36 import androidx.compose.material3.FloatingActionButton 14 37 import androidx.compose.material3.HorizontalDivider ··· 20 43 import androidx.compose.material3.NavigationBar 21 44 import androidx.compose.material3.NavigationBarItem 22 45 import androidx.compose.material3.NavigationDrawerItem 46 + import androidx.compose.material3.OutlinedTextField 23 47 import androidx.compose.material3.Scaffold 24 - import androidx.compose.material3.SheetValue 25 48 import androidx.compose.material3.Text 49 + import androidx.compose.material3.TextButton 26 50 import androidx.compose.material3.TopAppBarColors 27 51 import androidx.compose.material3.TopAppBarDefaults 52 + import androidx.compose.material3.rememberBottomSheetScaffoldState 28 53 import androidx.compose.material3.rememberModalBottomSheetState 29 54 import androidx.compose.material3.rememberTopAppBarState 30 55 import androidx.compose.runtime.Composable 56 + import androidx.compose.runtime.LaunchedEffect 31 57 import androidx.compose.runtime.getValue 32 58 import androidx.compose.runtime.mutableStateOf 33 59 import androidx.compose.runtime.remember 34 60 import androidx.compose.runtime.saveable.rememberSaveable 35 61 import androidx.compose.runtime.setValue 62 + import androidx.compose.ui.Alignment 36 63 import androidx.compose.ui.Modifier 37 64 import androidx.compose.ui.focus.FocusRequester 65 + import androidx.compose.ui.focus.focusRequester 38 66 import androidx.compose.ui.graphics.vector.ImageVector 39 67 import androidx.compose.ui.input.nestedscroll.nestedScroll 68 + import androidx.compose.ui.platform.LocalContext 69 + import androidx.compose.ui.platform.LocalSoftwareKeyboardController 40 70 import androidx.compose.ui.res.stringResource 71 + import androidx.compose.ui.text.input.ImeAction 41 72 import androidx.compose.ui.unit.dp 42 73 import industries.geesawra.jerryno.datalayer.TimelineViewModel 43 74 import kotlinx.coroutines.CoroutineScope ··· 59 90 timelineViewModel: TimelineViewModel, 60 91 coroutineScope: CoroutineScope 61 92 ) { 62 - var showBottomSheet by remember { mutableStateOf(false) } 63 - var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.HOME) } 64 - val modalSheetState = rememberModalBottomSheetState( 65 - skipPartiallyExpanded = true, 66 - confirmValueChange = { sv -> 67 - sv != SheetValue.PartiallyExpanded 93 + val scaffoldState = rememberBottomSheetScaffoldState( 94 + bottomSheetState = rememberModalBottomSheetState( 95 + skipPartiallyExpanded = true // Keep true if you only want fully expanded or hidden 96 + ), 97 + ) 98 + var postText by remember { mutableStateOf("") } 99 + val maxChars = 300 100 + 101 + val focusRequester = remember { FocusRequester() } 102 + val keyboardController = LocalSoftwareKeyboardController.current 103 + val context = LocalContext.current 104 + 105 + LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { 106 + if (scaffoldState.bottomSheetState.isVisible) { 107 + focusRequester.requestFocus() 108 + keyboardController?.show() 109 + } else { 110 + keyboardController?.hide() 111 + } 112 + } 113 + 114 + val pickMedia = 115 + rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(4)) { uris -> 116 + if (uris.isEmpty()) { 117 + return@rememberLauncherForActivityResult 118 + } 119 + 120 + val urisMap = uris.associateWith { 121 + val mimeType: String? = context.contentResolver.getType(it) 122 + mimeType?.let { 123 + if (mimeType.startsWith("image/")) { 124 + return@associateWith "image" 125 + } else if (mimeType.startsWith("video/")) { 126 + return@associateWith "video" 127 + } 128 + } 129 + 130 + return@associateWith null 131 + } 132 + 133 + if (urisMap.size > 1 && urisMap.values.find { 134 + it?.let { 135 + return@find (it == "video") 136 + } 137 + 138 + return@find false 139 + } != null) { 140 + Toast.makeText( 141 + context, 142 + "Can only post up to 1 video or 4 pictures", 143 + Toast.LENGTH_SHORT 144 + ).show() 145 + } 146 + } 147 + 148 + 149 + BottomSheetScaffold( 150 + scaffoldState = scaffoldState, 151 + sheetPeekHeight = 0.dp, 152 + modifier = Modifier.consumeWindowInsets(WindowInsets.ime), 153 + sheetContent = { 154 + Box( 155 + modifier = Modifier 156 + .fillMaxWidth() 157 + .windowInsetsPadding(WindowInsets.ime) 158 + .padding(16.dp), 159 + ) { 160 + Column( 161 + modifier = Modifier 162 + .fillMaxWidth() 163 + .height(350.dp), 164 + horizontalAlignment = Alignment.CenterHorizontally 165 + ) { 166 + Text( 167 + "New Post", 168 + style = MaterialTheme.typography.titleLarge, 169 + modifier = Modifier.padding(bottom = 16.dp) 170 + ) 171 + 172 + val charCount = remember { mutableStateOf(0) } 173 + val wasEdited = remember { mutableStateOf(false) } 174 + 175 + OutlinedTextField( 176 + keyboardActions = KeyboardActions( 177 + onDone = { 178 + this.defaultKeyboardAction(ImeAction.Done) 179 + // Optionally hide keyboard here 180 + keyboardController?.hide() 181 + } 182 + ), 183 + value = postText, 184 + onValueChange = { 185 + wasEdited.value = true 186 + postText = it 187 + charCount.value = it.length 188 + }, 189 + modifier = Modifier 190 + .fillMaxWidth() 191 + .focusRequester(focusRequester) 192 + .weight(1f), // TextField takes available space, crucial for resizing 193 + label = { 194 + if (wasEdited.value) { 195 + Text( 196 + text = "${maxChars - charCount.value}", 197 + color = if (postText.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 198 + ) 199 + // return@OutlinedTextField // This return might be causing label issues, consider removing or restructuring 200 + } else { 201 + Text( 202 + text = "Less cringe this time, okay?", 203 + ) 204 + } 205 + }, 206 + isError = postText.length > maxChars 207 + ) 208 + 209 + Spacer(modifier = Modifier.padding(4.dp)) // Reduced spacer, was Modifier.height(8.dp) 210 + 211 + Row( 212 + modifier = Modifier.fillMaxWidth(), 213 + horizontalArrangement = Arrangement.SpaceBetween, 214 + verticalAlignment = Alignment.CenterVertically 215 + ) { 216 + TextButton( 217 + onClick = { 218 + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 219 + } 220 + ) { 221 + Icon(Icons.Default.Attachment, contentDescription = "Attach media") 222 + } 223 + Button( 224 + onClick = { 225 + if (postText.isNotBlank() && postText.length <= maxChars) { 226 + // viewModel.postSkeet(postText) // Example action 227 + coroutineScope.launch { 228 + scaffoldState.bottomSheetState.hide() 229 + } 230 + postText = "" // Clear the text field 231 + wasEdited.value = false // Reset edited state 232 + } 233 + }, 234 + enabled = postText.isNotBlank() && postText.length <= maxChars 235 + ) { 236 + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Post") 237 + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 238 + Text("Skeet") 239 + } 240 + } 241 + } 242 + } 243 + }, 244 + content = { paddingValues -> 245 + InnerTimelineView( 246 + modifier = Modifier.padding(paddingValues), 247 + coroutineScope = coroutineScope, 248 + timelineViewModel = timelineViewModel, 249 + ) { 250 + coroutineScope.launch { 251 + scaffoldState.bottomSheetState.expand() 252 + } 253 + } 68 254 } 69 255 ) 70 - val focusRequester = remember { FocusRequester() } 256 + } 257 + 258 + @OptIn(ExperimentalMaterial3Api::class) 259 + @Composable 260 + private fun InnerTimelineView( 261 + modifier: Modifier = Modifier, 262 + coroutineScope: CoroutineScope, 263 + timelineViewModel: TimelineViewModel, 264 + fobOnClick: () -> Unit // Changed to fobOnClick to avoid confusion with FAB acronym 265 + ) { 266 + var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.HOME) } 71 267 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( 72 268 rememberTopAppBarState() 73 269 ) 74 270 val listState = rememberLazyListState() 75 271 76 - 77 272 ModalNavigationDrawer( 273 + modifier = modifier, 78 274 drawerContent = { 79 275 ModalDrawerSheet { 80 276 Text("Drawer title", modifier = Modifier.padding(16.dp)) ··· 114 310 }, 115 311 floatingActionButton = { 116 312 FloatingActionButton( 117 - onClick = { 118 - showBottomSheet = true 119 - }, 313 + onClick = fobOnClick 120 314 ) { 121 315 Icon(Icons.Filled.Create, "Post") 122 316 } ··· 146 340 state = listState, 147 341 modifier = Modifier.padding(values) 148 342 ) 149 - 150 - if (showBottomSheet) { 151 - ComposeView( 152 - modalSheetState = modalSheetState, 153 - focusRequester = focusRequester, 154 - timelineViewModel = timelineViewModel, 155 - onDismissRequest = { 156 - showBottomSheet = false 157 - } 158 - ) 159 - } 160 343 } 161 344 } 162 - } 345 + }
+12 -3
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 319 319 val r = BlueskyJson.encodeAsJsonContent( 320 320 Post( 321 321 text = content, 322 - createdAt = Clock.System.now() 322 + createdAt = Clock.System.now(), 323 + // embed = PostEmbedUnion.Images( 324 + // value = Images( 325 + // images = List<ImagesImage>(2, init = { 326 + // ImagesImage( 327 + // image = Blob() 328 + // ) 329 + // }) 330 + // ) 331 + // ) 323 332 ) 324 333 ) 325 334 client!!.createRecord( ··· 329 338 record = r, 330 339 ) 331 340 ) 332 - 333 - 334 341 } 342 + 343 + // suspend fun uploadImages(images: List<>) 335 344 }