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: refactor ComposeView out

geesawra 444ae5ae e7dc4c63

+252 -227
+251 -4
app/src/main/java/industries/geesawra/jerryno/ComposeView.kt
··· 1 1 package industries.geesawra.jerryno 2 2 3 + import android.content.Context 4 + import android.net.Uri 5 + import android.widget.Toast 6 + import androidx.activity.compose.rememberLauncherForActivityResult 7 + import androidx.activity.result.PickVisualMediaRequest 8 + import androidx.activity.result.contract.ActivityResultContracts 9 + import androidx.compose.foundation.layout.Arrangement 10 + import androidx.compose.foundation.layout.Box 11 + import androidx.compose.foundation.layout.Column 12 + import androidx.compose.foundation.layout.Row 13 + import androidx.compose.foundation.layout.Spacer 14 + import androidx.compose.foundation.layout.WindowInsets 15 + import androidx.compose.foundation.layout.fillMaxSize 16 + import androidx.compose.foundation.layout.fillMaxWidth 17 + import androidx.compose.foundation.layout.heightIn 18 + import androidx.compose.foundation.layout.ime 19 + import androidx.compose.foundation.layout.padding 20 + import androidx.compose.foundation.layout.size 21 + import androidx.compose.foundation.layout.windowInsetsPadding 22 + import androidx.compose.foundation.text.KeyboardActions 23 + import androidx.compose.material.icons.Icons 24 + import androidx.compose.material.icons.automirrored.filled.Send 25 + import androidx.compose.material.icons.filled.CameraRoll 26 + import androidx.compose.material3.BottomSheetScaffoldState 27 + import androidx.compose.material3.Button 28 + import androidx.compose.material3.ButtonDefaults 29 + import androidx.compose.material3.Card 30 + import androidx.compose.material3.CircularProgressIndicator 3 31 import androidx.compose.material3.ExperimentalMaterial3Api 4 - import androidx.compose.material3.SheetState 32 + import androidx.compose.material3.Icon 33 + import androidx.compose.material3.MaterialTheme 34 + import androidx.compose.material3.OutlinedTextField 35 + import androidx.compose.material3.Text 36 + import androidx.compose.material3.TextButton 5 37 import androidx.compose.runtime.Composable 38 + import androidx.compose.runtime.LaunchedEffect 39 + import androidx.compose.runtime.getValue 40 + import androidx.compose.runtime.mutableIntStateOf 41 + import androidx.compose.runtime.mutableStateOf 42 + import androidx.compose.runtime.remember 43 + import androidx.compose.runtime.setValue 44 + import androidx.compose.ui.Alignment 45 + import androidx.compose.ui.Modifier 6 46 import androidx.compose.ui.focus.FocusRequester 47 + import androidx.compose.ui.focus.focusRequester 48 + import androidx.compose.ui.platform.LocalSoftwareKeyboardController 49 + import androidx.compose.ui.text.input.ImeAction 50 + import androidx.compose.ui.unit.dp 7 51 import industries.geesawra.jerryno.datalayer.TimelineViewModel 52 + import kotlinx.coroutines.CoroutineScope 53 + import kotlinx.coroutines.launch 8 54 9 55 @OptIn(ExperimentalMaterial3Api::class) 10 56 @Composable 11 57 fun ComposeView( 12 - modalSheetState: SheetState, 13 - focusRequester: FocusRequester, 58 + context: Context, 59 + coroutineScope: CoroutineScope, 14 60 timelineViewModel: TimelineViewModel, 15 - onDismissRequest: () -> Unit, 61 + scaffoldState: BottomSheetScaffoldState 16 62 ) { 63 + val focusRequester = remember { FocusRequester() } 64 + val keyboardController = LocalSoftwareKeyboardController.current 65 + var postText by remember { mutableStateOf("") } 66 + val maxChars = 300 67 + 68 + val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } 69 + val mediaSelectedIsVideo = remember { mutableStateOf(false) } 70 + 71 + LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { 72 + if (scaffoldState.bottomSheetState.isVisible) { 73 + focusRequester.requestFocus() 74 + keyboardController?.show() 75 + } else { 76 + keyboardController?.hide() 77 + mediaSelected.value = mapOf() 78 + } 79 + } 80 + 81 + val pickMedia = 82 + rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(maxItems = 4)) { uris -> 83 + if (uris.isEmpty()) { 84 + return@rememberLauncherForActivityResult 85 + } 86 + 87 + val urisMap = uris.associateWith { 88 + val mimeType: String? = context.contentResolver.getType(it) 89 + mimeType?.let { 90 + if (mimeType.startsWith("image/")) { 91 + return@associateWith "image" 92 + } else if (mimeType.startsWith("video/")) { 93 + return@associateWith "video" 94 + } 95 + } 96 + 97 + return@associateWith null 98 + } 99 + 100 + if (urisMap.size > 1 && urisMap.values.find { 101 + it?.let { 102 + return@find (it == "video") 103 + } 104 + 105 + return@find false 106 + } != null) { 107 + Toast.makeText( 108 + context, 109 + "Can only post up to 1 video or 4 pictures", 110 + Toast.LENGTH_SHORT 111 + ).show() 112 + 113 + return@rememberLauncherForActivityResult 114 + } 115 + 116 + if (urisMap.size == 1 && urisMap.values.first() == "video") { 117 + mediaSelectedIsVideo.value = true 118 + } 119 + 120 + mediaSelected.value = urisMap 121 + } 122 + 123 + val uploadingPost = remember { mutableStateOf(false) } 124 + Box( 125 + modifier = Modifier 126 + .fillMaxWidth() 127 + .windowInsetsPadding( 128 + WindowInsets.ime 129 + ) 130 + .padding(16.dp) // General content padding 131 + ) { 132 + Column( 133 + modifier = Modifier 134 + .fillMaxWidth(), // Removed .fillMaxHeight() 135 + horizontalAlignment = Alignment.CenterHorizontally 136 + ) { 137 + Row { 138 + Text( 139 + "New Post", 140 + style = MaterialTheme.typography.titleLarge, 141 + modifier = Modifier.padding(bottom = 16.dp) 142 + ) 143 + } 144 + 145 + val charCount = remember { mutableIntStateOf(0) } 146 + val wasEdited = remember { mutableStateOf(false) } 147 + 148 + OutlinedTextField( 149 + keyboardActions = KeyboardActions( 150 + onDone = { 151 + this.defaultKeyboardAction(ImeAction.Done) 152 + keyboardController?.hide() 153 + } 154 + ), 155 + value = postText, 156 + onValueChange = { 157 + wasEdited.value = true 158 + postText = it 159 + charCount.intValue = it.length 160 + }, 161 + modifier = Modifier 162 + .fillMaxWidth() 163 + .weight(1f) 164 + .focusRequester(focusRequester), 165 + label = { 166 + if (wasEdited.value) { 167 + Text( 168 + text = "${maxChars - charCount.intValue}", 169 + color = if (postText.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 170 + ) 171 + } else { 172 + Text( 173 + text = "Less cringe this time, okay?", 174 + ) 175 + } 176 + }, 177 + isError = postText.length > maxChars 178 + ) 179 + 180 + Spacer(modifier = Modifier.padding(4.dp)) 181 + 182 + if (mediaSelected.value.isNotEmpty()) { 183 + Card( 184 + modifier = Modifier 185 + .heightIn(max = 180.dp) 186 + .fillMaxWidth() 187 + .padding(8.dp) // This padding is for the Card itself, not the Box's content 188 + ) { 189 + PostImageGallery( 190 + modifier = Modifier 191 + .fillMaxSize() 192 + .padding(8.dp), 193 + images = mediaSelected.value.keys.map { 194 + Image( 195 + url = it.toString(), 196 + alt = "" 197 + ) 198 + }, 199 + ) 200 + } 201 + } 202 + 203 + Spacer(modifier = Modifier.padding(4.dp)) 204 + 205 + Row( 206 + modifier = Modifier.fillMaxWidth(), 207 + horizontalArrangement = Arrangement.SpaceBetween, 208 + verticalAlignment = Alignment.CenterVertically 209 + ) { 210 + TextButton( 211 + onClick = { 212 + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 213 + } 214 + ) { 215 + Icon(Icons.Default.CameraRoll, contentDescription = "Attach media") 216 + } 217 + 218 + if (uploadingPost.value) { 219 + CircularProgressIndicator() 220 + } 221 + 222 + val postButtonEnabled = remember { mutableStateOf(true) } 223 + Button( 224 + onClick = { 225 + if (postText.isNotBlank() && postText.length <= maxChars) { 226 + coroutineScope.launch { 227 + postButtonEnabled.value = false 228 + uploadingPost.value = true 229 + timelineViewModel.post( 230 + postText, 231 + if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 232 + .ifEmpty { null } else null, 233 + if (mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 234 + .firstOrNull() 235 + else null, 236 + ).onSuccess { 237 + scaffoldState.bottomSheetState.hide() 238 + postText = "" 239 + wasEdited.value = false 240 + postButtonEnabled.value = true 241 + uploadingPost.value = false 242 + }.onFailure { 243 + postButtonEnabled.value = true 244 + Toast.makeText( 245 + context, 246 + "Could not post: ${it.message}", 247 + Toast.LENGTH_LONG 248 + ).show() 249 + uploadingPost.value = false 250 + postButtonEnabled.value = true 251 + } 252 + } 253 + } 254 + }, 255 + enabled = (postText.isNotBlank() || mediaSelected.value.isNotEmpty()) && postText.length <= maxChars && postButtonEnabled.value 256 + ) { 257 + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Post") 258 + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 259 + Text("Skeet") 260 + } 261 + } 262 + } 263 + } 17 264 18 265 }
+1 -223
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 1 1 package industries.geesawra.jerryno 2 2 3 3 // import androidx.compose.foundation.layout.height // Will be removed for the sheet content Box 4 - import android.net.Uri 5 4 import android.widget.Toast 6 - import androidx.activity.compose.rememberLauncherForActivityResult 7 - import androidx.activity.result.PickVisualMediaRequest 8 - import androidx.activity.result.contract.ActivityResultContracts 9 5 import androidx.annotation.StringRes 10 6 import androidx.compose.foundation.clickable 11 7 import androidx.compose.foundation.layout.Arrangement 12 - import androidx.compose.foundation.layout.Box 13 - import androidx.compose.foundation.layout.Column 14 8 import androidx.compose.foundation.layout.Row 15 9 import androidx.compose.foundation.layout.Spacer 16 10 import androidx.compose.foundation.layout.WindowInsets 17 11 import androidx.compose.foundation.layout.displayCutout 18 12 import androidx.compose.foundation.layout.fillMaxSize 19 - import androidx.compose.foundation.layout.fillMaxWidth 20 - import androidx.compose.foundation.layout.heightIn 21 - import androidx.compose.foundation.layout.ime 22 13 import androidx.compose.foundation.layout.padding 23 14 import androidx.compose.foundation.layout.size 24 15 import androidx.compose.foundation.layout.windowInsetsPadding 25 16 import androidx.compose.foundation.lazy.rememberLazyListState 26 17 import androidx.compose.foundation.shape.CircleShape 27 - import androidx.compose.foundation.text.KeyboardActions 28 18 import androidx.compose.material.icons.Icons 29 - import androidx.compose.material.icons.automirrored.filled.Send 30 - import androidx.compose.material.icons.filled.CameraRoll 31 19 import androidx.compose.material.icons.filled.Create 32 20 import androidx.compose.material.icons.filled.Home 33 21 import androidx.compose.material.icons.filled.Notifications 34 22 import androidx.compose.material.icons.filled.Tag 35 23 import androidx.compose.material3.BottomSheetScaffold 36 - import androidx.compose.material3.Button 37 - import androidx.compose.material3.ButtonDefaults 38 - import androidx.compose.material3.Card 39 - import androidx.compose.material3.CircularProgressIndicator 40 24 import androidx.compose.material3.DrawerValue 41 25 import androidx.compose.material3.ExperimentalMaterial3Api 42 26 import androidx.compose.material3.FloatingActionButton ··· 49 33 import androidx.compose.material3.NavigationBarItem 50 34 import androidx.compose.material3.NavigationDrawerItem 51 35 import androidx.compose.material3.NavigationDrawerItemDefaults 52 - import androidx.compose.material3.OutlinedTextField 53 36 import androidx.compose.material3.Scaffold 54 37 import androidx.compose.material3.Text 55 - import androidx.compose.material3.TextButton 56 38 import androidx.compose.material3.TopAppBar 57 39 import androidx.compose.material3.TopAppBarColors 58 40 import androidx.compose.material3.TopAppBarDefaults ··· 64 46 import androidx.compose.runtime.Composable 65 47 import androidx.compose.runtime.LaunchedEffect 66 48 import androidx.compose.runtime.getValue 67 - import androidx.compose.runtime.mutableIntStateOf 68 49 import androidx.compose.runtime.mutableStateOf 69 50 import androidx.compose.runtime.remember 70 51 import androidx.compose.runtime.saveable.rememberSaveable ··· 73 54 import androidx.compose.ui.Modifier 74 55 import androidx.compose.ui.draw.clip 75 56 import androidx.compose.ui.draw.shadow 76 - import androidx.compose.ui.focus.FocusRequester 77 - import androidx.compose.ui.focus.focusRequester 78 57 import androidx.compose.ui.graphics.vector.ImageVector 79 58 import androidx.compose.ui.input.nestedscroll.nestedScroll 80 59 import androidx.compose.ui.platform.LocalContext 81 - import androidx.compose.ui.platform.LocalSoftwareKeyboardController 82 60 import androidx.compose.ui.res.stringResource 83 61 import androidx.compose.ui.text.font.FontWeight 84 - import androidx.compose.ui.text.input.ImeAction 85 62 import androidx.compose.ui.unit.dp 86 63 import coil3.compose.AsyncImage 87 64 import industries.geesawra.jerryno.datalayer.TimelineViewModel ··· 110 87 skipPartiallyExpanded = true // Keep true if you only want fully expanded or hidden 111 88 ), 112 89 ) 113 - var postText by remember { mutableStateOf("") } 114 - val maxChars = 300 115 90 116 - val focusRequester = remember { FocusRequester() } 117 - val keyboardController = LocalSoftwareKeyboardController.current 118 91 val context = LocalContext.current 119 - val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } 120 - val mediaSelectedIsVideo = remember { mutableStateOf(false) } 121 - 122 - LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { 123 - if (scaffoldState.bottomSheetState.isVisible) { 124 - focusRequester.requestFocus() 125 - keyboardController?.show() 126 - } else { 127 - keyboardController?.hide() 128 - mediaSelected.value = mapOf() 129 - } 130 - } 131 - 132 - 133 - val pickMedia = 134 - rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(maxItems = 4)) { uris -> 135 - if (uris.isEmpty()) { 136 - return@rememberLauncherForActivityResult 137 - } 138 - 139 - val urisMap = uris.associateWith { 140 - val mimeType: String? = context.contentResolver.getType(it) 141 - mimeType?.let { 142 - if (mimeType.startsWith("image/")) { 143 - return@associateWith "image" 144 - } else if (mimeType.startsWith("video/")) { 145 - return@associateWith "video" 146 - } 147 - } 148 - 149 - return@associateWith null 150 - } 151 - 152 - if (urisMap.size > 1 && urisMap.values.find { 153 - it?.let { 154 - return@find (it == "video") 155 - } 156 - 157 - return@find false 158 - } != null) { 159 - Toast.makeText( 160 - context, 161 - "Can only post up to 1 video or 4 pictures", 162 - Toast.LENGTH_SHORT 163 - ).show() 164 - 165 - return@rememberLauncherForActivityResult 166 - } 167 - 168 - if (urisMap.size == 1 && urisMap.values.first() == "video") { 169 - mediaSelectedIsVideo.value = true 170 - } 171 - 172 - mediaSelected.value = urisMap 173 - } 174 92 175 93 176 94 BottomSheetScaffold( ··· 178 96 scaffoldState = scaffoldState, 179 97 sheetPeekHeight = 0.dp, 180 98 sheetContent = { 181 - val uploadingPost = remember { mutableStateOf(false) } 182 - Box( 183 - modifier = Modifier 184 - .fillMaxWidth() 185 - .windowInsetsPadding( 186 - WindowInsets.ime 187 - ) 188 - .padding(16.dp) // General content padding 189 - ) { 190 - Column( 191 - modifier = Modifier 192 - .fillMaxWidth(), // Removed .fillMaxHeight() 193 - horizontalAlignment = Alignment.CenterHorizontally 194 - ) { 195 - Row { 196 - Text( 197 - "New Post", 198 - style = MaterialTheme.typography.titleLarge, 199 - modifier = Modifier.padding(bottom = 16.dp) 200 - ) 201 - } 202 - 203 - val charCount = remember { mutableIntStateOf(0) } 204 - val wasEdited = remember { mutableStateOf(false) } 205 - 206 - OutlinedTextField( 207 - keyboardActions = KeyboardActions( 208 - onDone = { 209 - this.defaultKeyboardAction(ImeAction.Done) 210 - keyboardController?.hide() 211 - } 212 - ), 213 - value = postText, 214 - onValueChange = { 215 - wasEdited.value = true 216 - postText = it 217 - charCount.intValue = it.length 218 - }, 219 - modifier = Modifier 220 - .fillMaxWidth() 221 - .weight(1f) 222 - .focusRequester(focusRequester), 223 - label = { 224 - if (wasEdited.value) { 225 - Text( 226 - text = "${maxChars - charCount.intValue}", 227 - color = if (postText.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 228 - ) 229 - } else { 230 - Text( 231 - text = "Less cringe this time, okay?", 232 - ) 233 - } 234 - }, 235 - isError = postText.length > maxChars 236 - ) 237 - 238 - Spacer(modifier = Modifier.padding(4.dp)) 239 - 240 - if (mediaSelected.value.isNotEmpty()) { 241 - Card( 242 - modifier = Modifier 243 - .heightIn(max = 180.dp) 244 - .fillMaxWidth() 245 - .padding(8.dp) // This padding is for the Card itself, not the Box's content 246 - ) { 247 - PostImageGallery( 248 - modifier = Modifier 249 - .fillMaxSize() 250 - .padding(8.dp), 251 - images = mediaSelected.value.keys.map { 252 - Image( 253 - url = it.toString(), 254 - alt = "" 255 - ) 256 - }, 257 - ) 258 - } 259 - } 260 - 261 - Spacer(modifier = Modifier.padding(4.dp)) 262 - 263 - Row( 264 - modifier = Modifier.fillMaxWidth(), 265 - horizontalArrangement = Arrangement.SpaceBetween, 266 - verticalAlignment = Alignment.CenterVertically 267 - ) { 268 - TextButton( 269 - onClick = { 270 - pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 271 - } 272 - ) { 273 - Icon(Icons.Default.CameraRoll, contentDescription = "Attach media") 274 - } 275 - 276 - if (uploadingPost.value) { 277 - CircularProgressIndicator() 278 - } 279 - 280 - val postButtonEnabled = remember { mutableStateOf(true) } 281 - Button( 282 - onClick = { 283 - if (postText.isNotBlank() && postText.length <= maxChars) { 284 - coroutineScope.launch { 285 - postButtonEnabled.value = false 286 - uploadingPost.value = true 287 - timelineViewModel.post( 288 - postText, 289 - if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 290 - .ifEmpty { null } else null, 291 - if (mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 292 - .firstOrNull() 293 - else null, 294 - ).onSuccess { 295 - scaffoldState.bottomSheetState.hide() 296 - postText = "" 297 - wasEdited.value = false 298 - postButtonEnabled.value = true 299 - uploadingPost.value = false 300 - }.onFailure { 301 - postButtonEnabled.value = true 302 - Toast.makeText( 303 - context, 304 - "Could not post: ${it.message}", 305 - Toast.LENGTH_LONG 306 - ).show() 307 - uploadingPost.value = false 308 - postButtonEnabled.value = true 309 - } 310 - } 311 - } 312 - }, 313 - enabled = (postText.isNotBlank() || mediaSelected.value.isNotEmpty()) && postText.length <= maxChars && postButtonEnabled.value 314 - ) { 315 - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Post") 316 - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 317 - Text("Skeet") 318 - } 319 - } 320 - } 321 - } 99 + ComposeView(context, coroutineScope, timelineViewModel, scaffoldState) 322 100 }, 323 101 content = { paddingValues -> 324 102 InnerTimelineView(