diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsBottomSheet.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsBottomSheet.kt index f00b7239..2204d671 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsBottomSheet.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsBottomSheet.kt @@ -98,11 +98,13 @@ fun CommentsBottomSheet(navigator: DestinationsNavigator) { is AsyncResource.Content -> { val comments by state.data.second.comments.collectAsStateWithLifecycle() val currentUserId = state.data.first.id + val createContentState by viewModel.createContentState.collectAsStateWithLifecycle() Column { CommentsBottomSheetContent( comments = comments, currentUserId = currentUserId, + createContentState = createContentState, onEvent = viewModel::onEvent, ) } @@ -115,9 +117,9 @@ fun CommentsBottomSheet(navigator: DestinationsNavigator) { private fun ColumnScope.CommentsBottomSheetContent( comments: List, currentUserId: String, + createContentState: CreateContentState, onEvent: (Event) -> Unit, ) { - var createCommentData: CreateCommentData? by remember { mutableStateOf(null) } var expandedCommentId: String? by remember { mutableStateOf(null) } Text( @@ -141,9 +143,7 @@ private fun ColumnScope.CommentsBottomSheetContent( onExpandClick = { expandedCommentId = comment.id.takeUnless { it == expandedCommentId } }, - onReplyClick = { commentId -> - createCommentData = CreateCommentData(commentId) - }, + onReplyClick = { commentId -> onEvent(Event.OnReply(commentId)) }, onEvent = onEvent, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), ) @@ -154,7 +154,7 @@ private fun ColumnScope.CommentsBottomSheetContent( } FloatingActionButton( - onClick = { createCommentData = CreateCommentData() }, + onClick = { onEvent(Event.OnAddContent) }, modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), shape = CircleShape, ) { @@ -166,17 +166,13 @@ private fun ColumnScope.CommentsBottomSheetContent( } } - if (createCommentData != null) { - CreateContentBottomSheet( - title = "Add comment", - onDismiss = { createCommentData = null }, - requireText = true, - onPost = { text, attachments -> - onEvent(Event.OnPost(text, createCommentData?.replyParentId, attachments)) - createCommentData = null - }, - ) - } + CreateContentBottomSheet( + state = createContentState, + title = "Add comment", + onDismiss = { onEvent(Event.OnContentCreateDismiss) }, + onPost = { text, attachments -> onEvent(Event.OnPost(text, attachments)) }, + requireText = true, + ) } @Composable @@ -311,5 +307,3 @@ private fun Comment( } } } - -@JvmInline private value class CreateCommentData(val replyParentId: String? = null) diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsSheetViewModel.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsSheetViewModel.kt index be36fd28..d4852338 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsSheetViewModel.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CommentsSheetViewModel.kt @@ -41,7 +41,10 @@ import io.getstream.feeds.android.sample.util.notNull import io.getstream.feeds.android.sample.util.withFirstContent import io.getstream.feeds.android.sample.utils.logResult import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -79,6 +82,10 @@ constructor( .map { loadingState -> loadingState.map { it.first to it.second.state } } .stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading) + private var replyParentId: String? = null + private val _createContentState = MutableStateFlow(CreateContentState.Hidden) + val createContentState: StateFlow = _createContentState.asStateFlow() + init { activity.withFirstContent(viewModelScope) { get().logResult(TAG, "Loading activity: $activityId") @@ -89,9 +96,12 @@ constructor( when (event) { Event.OnScrollToBottom -> loadMore() is Event.OnEdit -> edit(id = event.commentId, text = event.text) - is Event.OnPost -> post(event.text, event.replyParentId, event.attachments) + is Event.OnPost -> post(event.text, replyParentId, event.attachments) is Event.OnLike -> toggleLike(event.comment) is Event.OnDelete -> delete(event.commentId) + Event.OnAddContent -> showAddContent(true) + Event.OnContentCreateDismiss -> showAddContent(false) + is Event.OnReply -> showAddContent(true, event.parentId) } } @@ -130,49 +140,67 @@ constructor( } } + private fun showAddContent(show: Boolean, replyParentId: String? = null) { + _createContentState.value = + if (show) CreateContentState.Composing else CreateContentState.Hidden + this.replyParentId = replyParentId + } + private fun post(text: String, replyParentId: String?, attachments: List) { + _createContentState.value = CreateContentState.Posting + activity.withFirstContent(viewModelScope) { val attachmentFiles = context.copyToCache(attachments).getOrElse { error -> Log.e(TAG, "Failed to copy attachments", error) + _createContentState.value = CreateContentState.Composing return@withFirstContent } - addComment( - ActivityAddCommentRequest( - comment = text, - activityId = activityId, - parentId = replyParentId, - createNotificationActivity = true, - attachmentUploads = - attachmentFiles.map { - FeedUploadPayload(file = it, type = FileType.Image("jpeg")) - }, - ), - attachmentUploadProgress = { file, progress -> - Log.d(TAG, "Uploading attachment: ${file.type}, progress: $progress") - }, - ) - .logResult(TAG, "Adding comment to activity: $activityId") + val result = + addComment( + ActivityAddCommentRequest( + comment = text, + activityId = activityId, + parentId = replyParentId, + createNotificationActivity = true, + attachmentUploads = + attachmentFiles.map { + FeedUploadPayload(file = it, type = FileType.Image("jpeg")) + }, + ), + attachmentUploadProgress = { file, progress -> + Log.d(TAG, "Uploading attachment: ${file.type}, progress: $progress") + }, + ) + .logResult(TAG, "Adding comment to activity: $activityId") deleteFiles(attachmentFiles) + + _createContentState.value = + result.fold( + onSuccess = { CreateContentState.Hidden }, + onFailure = { CreateContentState.Composing }, + ) } } sealed interface Event { data object OnScrollToBottom : Event + data object OnAddContent : Event + data class OnLike(val comment: ThreadedCommentData) : Event data class OnDelete(val commentId: String) : Event data class OnEdit(val commentId: String, val text: String) : Event - data class OnPost( - val text: String, - val replyParentId: String?, - val attachments: List, - ) : Event + data class OnPost(val text: String, val attachments: List) : Event + + data object OnContentCreateDismiss : Event + + data class OnReply(val parentId: String) : Event } companion object { diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreateContentBottomSheet.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreateContentBottomSheet.kt index 53adabe6..a3252699 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreateContentBottomSheet.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreateContentBottomSheet.kt @@ -28,12 +28,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState @@ -41,6 +43,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,22 +54,39 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +enum class CreateContentState { + Hidden, + Composing, + Posting, +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateContentBottomSheet( + state: CreateContentState, title: String, onDismiss: () -> Unit, onPost: (text: String, attachments: List) -> Unit, requireText: Boolean, - extraActions: @Composable RowScope.() -> Unit = {}, + extraActions: @Composable RowScope.(inputEnabled: Boolean) -> Unit = {}, ) { + if (state == CreateContentState.Hidden) return + + val state by rememberUpdatedState(state) + val sheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + // Do not allow hiding while posting + confirmValueChange = { it != SheetValue.Hidden || state != CreateContentState.Posting }, + ) + val inputEnabled = state == CreateContentState.Composing + var postText by remember { mutableStateOf("") } - var attachments by remember { mutableStateOf>(emptyList()) } - val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var attachments by remember { mutableStateOf(emptyList()) } ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = bottomSheetState, + sheetState = sheetState, modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { @@ -78,9 +98,13 @@ fun CreateContentBottomSheet( ) { Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Companion.Bold) - val canPost = attachments.isNotEmpty() && !requireText || postText.isNotBlank() - TextButton(onClick = { onPost(postText, attachments) }, enabled = canPost) { - Text(text = "Submit", fontWeight = FontWeight.Companion.Medium) + if (state == CreateContentState.Posting) { + CircularProgressIndicator() + } else { + val canPost = attachments.isNotEmpty() && !requireText || postText.isNotBlank() + TextButton(onClick = { onPost(postText, attachments) }, enabled = canPost) { + Text(text = "Submit", fontWeight = FontWeight.Companion.Medium) + } } } @@ -88,6 +112,7 @@ fun CreateContentBottomSheet( OutlinedTextField( value = postText, onValueChange = { postText = it }, + enabled = inputEnabled, placeholder = { Text("What's on your mind?") }, modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), minLines = 3, @@ -106,6 +131,7 @@ fun CreateContentBottomSheet( AttachmentButton( hasAttachment = hasAttachments, onAttachmentsSelected = { uris -> attachments = uris }, + enabled = inputEnabled, ) if (hasAttachments) { @@ -117,21 +143,26 @@ fun CreateContentBottomSheet( } // Display any extra actions passed to the bottom sheet - extraActions() + extraActions(inputEnabled) } } } } @Composable -private fun AttachmentButton(hasAttachment: Boolean, onAttachmentsSelected: (List) -> Unit) { +private fun AttachmentButton( + hasAttachment: Boolean, + onAttachmentsSelected: (List) -> Unit, + enabled: Boolean, +) { val activityLauncher = rememberLauncherForActivityResult(PickMultipleVisualMedia(), onAttachmentsSelected) IconButton( onClick = { activityLauncher.launch(PickVisualMediaRequest(mediaType = PickVisualMedia.ImageOnly)) - } + }, + enabled = enabled, ) { Icon( painter = painterResource(android.R.drawable.ic_menu_gallery), diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreatePollButton.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreatePollButton.kt index d638b6db..6b1c8a21 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreatePollButton.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/CreatePollButton.kt @@ -60,10 +60,10 @@ import io.getstream.feeds.android.sample.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreatePollButton(onCreatePoll: (PollFormData) -> Unit) { +fun CreatePollButton(onCreatePoll: (PollFormData) -> Unit, enabled: Boolean) { var showPollBottomSheet by remember { mutableStateOf(false) } - IconButton(onClick = { showPollBottomSheet = true }) { + IconButton(onClick = { showPollBottomSheet = true }, enabled = enabled) { Icon( painter = painterResource(R.drawable.poll), contentDescription = "Create Poll", diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedViewModel.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedViewModel.kt index 406d8328..0a39b0ac 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedViewModel.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedViewModel.kt @@ -50,7 +50,10 @@ import io.getstream.feeds.android.sample.util.withFirstContent import io.getstream.feeds.android.sample.utils.logResult import javax.inject.Inject import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow @@ -82,6 +85,9 @@ constructor( .map { asyncResource -> asyncResource.map(Feed::state) } .stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading) + private val _createContentState = MutableStateFlow(CreateContentState.Hidden) + val createContentState: StateFlow = _createContentState.asStateFlow() + val pollController = FeedPollController( scope = viewModelScope, @@ -173,7 +179,17 @@ constructor( } } + fun onCreateClick() { + _createContentState.value = CreateContentState.Composing + } + + fun onContentCreateDismiss() { + _createContentState.value = CreateContentState.Hidden + } + fun onCreatePost(text: String, attachments: List) { + _createContentState.value = CreateContentState.Posting + feed.withFirstContent(viewModelScope) { val attachmentFiles = application @@ -181,25 +197,35 @@ constructor( .notifyOnFailure { "Failed to copy attachments" } .getOrElse { error -> Log.e(TAG, "Failed to copy attachments", error) + _createContentState.value = CreateContentState.Composing return@withFirstContent } - addActivity( - FeedAddActivityRequest( - type = "activity", - text = text, - feeds = listOf(fid.rawValue), - attachmentUploads = - attachmentFiles.map { FeedUploadPayload(it, FileType.Image("jpeg")) }, - ), - attachmentUploadProgress = { file, progress -> - Log.d(TAG, "Uploading attachment: ${file.type}, progress: $progress") - }, - ) - .logResult(TAG, "Creating activity with text: $text") - .notifyOnFailure { "Failed to create post" } + val result = + addActivity( + FeedAddActivityRequest( + type = "activity", + text = text, + feeds = listOf(fid.rawValue), + attachmentUploads = + attachmentFiles.map { + FeedUploadPayload(it, FileType.Image("jpeg")) + }, + ), + attachmentUploadProgress = { file, progress -> + Log.d(TAG, "Uploading attachment: ${file.type}, progress: $progress") + }, + ) + .logResult(TAG, "Creating activity with text: $text") + .notifyOnFailure { "Failed to create post" } deleteFiles(attachmentFiles) + + _createContentState.value = + result.fold( + onSuccess = { CreateContentState.Hidden }, + onFailure = { CreateContentState.Composing }, + ) } } diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedsScreen.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedsScreen.kt index dab0080b..c92dd36e 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedsScreen.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedsScreen.kt @@ -140,7 +140,7 @@ private fun FeedsScreenContent( viewModel: FeedViewModel, modifier: Modifier, ) { - var showCreatePostBottomSheet by remember { mutableStateOf(false) } + val createContentState by viewModel.createContentState.collectAsStateWithLifecycle() Column(modifier = modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) { @@ -209,7 +209,7 @@ private fun FeedsScreenContent( } FloatingActionButton( - onClick = { showCreatePostBottomSheet = true }, + onClick = viewModel::onCreateClick, modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), shape = CircleShape, ) { @@ -221,23 +221,14 @@ private fun FeedsScreenContent( } // Create Post Bottom Sheet - if (showCreatePostBottomSheet) { - CreateContentBottomSheet( - title = "Create post", - onDismiss = { showCreatePostBottomSheet = false }, - onPost = { postText, attachments -> - showCreatePostBottomSheet = false - viewModel.onCreatePost(postText, attachments) - }, - requireText = false, - extraActions = { - CreatePollButton { formData -> - showCreatePostBottomSheet = false - viewModel.onCreatePoll(formData) - } - }, - ) - } + CreateContentBottomSheet( + state = createContentState, + title = "Create post", + onDismiss = viewModel::onContentCreateDismiss, + onPost = viewModel::onCreatePost, + requireText = false, + extraActions = { enabled -> CreatePollButton(viewModel::onCreatePoll, enabled) }, + ) } } }