diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt index b863d3ece46..837cb7830ea 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt @@ -60,7 +60,6 @@ class DirectChannelInfoActivity : ComponentActivity() { private val viewModelFactory by lazy { ChannelInfoViewModelFactory( - context = applicationContext, cid = channelId, ) } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt index 85eaeb5e08c..e5a2664faca 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt @@ -62,7 +62,6 @@ class GroupChannelInfoActivity : ComponentActivity() { private val viewModelFactory by lazy { ChannelInfoViewModelFactory( - context = applicationContext, cid = channelId, ) } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt index 2e4c971e4ce..e3de6515cd6 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -75,7 +76,6 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.ChatUiConfig import io.getstream.chat.android.compose.ui.theme.CompoundComponentFactory import io.getstream.chat.android.compose.ui.theme.DirectChannelInfoTopBarParams -import io.getstream.chat.android.compose.ui.theme.GroupChannelInfoAddMembersButtonParams import io.getstream.chat.android.compose.ui.theme.GroupChannelInfoTopBarParams import io.getstream.chat.android.compose.ui.theme.TranslationConfig import io.getstream.chat.android.compose.ui.util.adaptivelayout.AdaptiveLayoutInfo @@ -107,6 +107,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch +import io.getstream.chat.android.compose.R as ComposeR class ChatsActivity : ComponentActivity() { @@ -330,7 +331,7 @@ class ChatsActivity : ComponentActivity() { onNavigateToFilesAttachments: () -> Unit, ) { val viewModelFactory = remember(channelId) { - ChannelInfoViewModelFactory(context = applicationContext, cid = channelId) + ChannelInfoViewModelFactory(cid = channelId) } val viewModel = viewModel(factory = viewModelFactory) @@ -384,7 +385,7 @@ class ChatsActivity : ComponentActivity() { onNavigateToChannel: (cid: String) -> Unit, ) { val viewModelFactory = remember(channelId) { - ChannelInfoViewModelFactory(context = applicationContext, cid = channelId) + ChannelInfoViewModelFactory(cid = channelId) } val viewModel = viewModel(factory = viewModelFactory) @@ -412,7 +413,7 @@ class ChatsActivity : ComponentActivity() { infoState = params.infoState, listState = params.listState, navigationIcon = { CloseButton(onClick = params.onNavigationIconClick) }, - onAddMembersClick = params.onAddMembersClick, + onActionClick = params.onActionClick, ) } } @@ -466,7 +467,7 @@ class ChatsActivity : ComponentActivity() { infoState: ChannelInfoViewState, listState: LazyListState, navigationIcon: @Composable () -> Unit, - onAddMembersClick: () -> Unit, + onActionClick: () -> Unit, ) { val elevation by animateDpAsState( targetValue = if (listState.canScrollBackward) { @@ -503,11 +504,13 @@ class ChatsActivity : ComponentActivity() { ), actions = { if (infoState is ChannelInfoViewState.Content && - infoState.options.contains(ChannelInfoViewState.Content.Option.AddMember) + infoState.options.any { option -> option is ChannelInfoViewState.Content.Option.EditChannel } ) { - ChatTheme.componentFactory.GroupChannelInfoAddMembersButton( - params = GroupChannelInfoAddMembersButtonParams(onClick = onAddMembersClick), - ) + OutlinedButton( + onClick = onActionClick, + ) { + Text(text = stringResource(id = ComposeR.string.stream_ui_channel_info_edit_action)) + } } }, ) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index bc9deeeae86..f1c2f6bcd48 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -871,15 +871,6 @@ public final class io/getstream/chat/android/compose/ui/channel/info/ComposableS public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } -public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoNameFieldKt { - public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoNameFieldKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function2; - public fun ()V - public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; -} - public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoOptionKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoOptionKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; @@ -923,19 +914,40 @@ public final class io/getstream/chat/android/compose/ui/channel/info/ComposableS public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } -public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelInfoScreenKt { - public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelInfoScreenKt; +public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelEditScreenKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelEditScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function2; public static field lambda-3 Lkotlin/jvm/functions/Function2; public static field lambda-4 Lkotlin/jvm/functions/Function2; public static field lambda-5 Lkotlin/jvm/functions/Function2; + public static field lambda-6 Lkotlin/jvm/functions/Function2; + public static field lambda-7 Lkotlin/jvm/functions/Function2; + public static field lambda-8 Lkotlin/jvm/functions/Function2; + public static field lambda-9 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-8$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-9$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + +public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelInfoScreenKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelInfoScreenKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; + public static field lambda-3 Lkotlin/jvm/functions/Function2; + public static field lambda-4 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/DirectChannelInfoScreenKt { @@ -4203,6 +4215,7 @@ public final class io/getstream/chat/android/compose/ui/theme/GroupChannelInfoMe public final class io/getstream/chat/android/compose/ui/theme/GroupChannelInfoTopBarParams { public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/state/messages/list/ChannelHeaderViewState;Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState;Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/ChannelHeaderViewState;Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState;Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/ChannelHeaderViewState; public final fun component2 ()Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState; public final fun component3 ()Landroidx/compose/foundation/lazy/LazyListState; @@ -4214,7 +4227,7 @@ public final class io/getstream/chat/android/compose/ui/theme/GroupChannelInfoTo public final fun getHeaderState ()Lio/getstream/chat/android/ui/common/state/messages/list/ChannelHeaderViewState; public final fun getInfoState ()Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState; public final fun getListState ()Landroidx/compose/foundation/lazy/LazyListState; - public final fun getOnAddMembersClick ()Lkotlin/jvm/functions/Function0; + public final fun getOnActionClick ()Lkotlin/jvm/functions/Function0; public final fun getOnNavigationIconClick ()Lkotlin/jvm/functions/Function0; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -6253,7 +6266,7 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamDesign$Color public final class io/getstream/chat/android/compose/ui/theme/StreamDesign$Colors { public static final field $stable I public static final field Companion Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors$Companion; - public synthetic fun (Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ColorScale;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ChromeScale;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ColorScale;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ChromeScale;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ColorScale; public final fun component10-0d7_KjU ()J public final fun component11-0d7_KjU ()J @@ -6313,11 +6326,12 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamDesign$Color public final fun component60-0d7_KjU ()J public final fun component61-0d7_KjU ()J public final fun component62-0d7_KjU ()J + public final fun component63-0d7_KjU ()J public final fun component7-0d7_KjU ()J public final fun component8-0d7_KjU ()J public final fun component9-0d7_KjU ()J - public final fun copy-Gzxa-5A (Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ColorScale;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ChromeScale;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ)Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors; - public static synthetic fun copy-Gzxa-5A$default (Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ColorScale;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ChromeScale;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJIILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors; + public final fun copy-hm1G8mE (Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ColorScale;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ChromeScale;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ)Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors; + public static synthetic fun copy-hm1G8mE$default (Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ColorScale;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ChromeScale;JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJIILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors; public fun equals (Ljava/lang/Object;)Z public final fun getAccentError-0d7_KjU ()J public final fun getAccentNeutral-0d7_KjU ()J @@ -6374,6 +6388,7 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamDesign$Color public final fun getChrome ()Lio/getstream/chat/android/compose/ui/theme/StreamDesign$ChromeScale; public final fun getSkeletonLoadingBase-0d7_KjU ()J public final fun getSkeletonLoadingHighlight-0d7_KjU ()J + public final fun getSystemCaret-0d7_KjU ()J public final fun getTextDisabled-0d7_KjU ()J public final fun getTextLink-0d7_KjU ()J public final fun getTextOnAccent-0d7_KjU ()J @@ -6904,8 +6919,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel : androidx/lifecycle/ViewModel { public static final field $stable I - public fun (Ljava/lang/String;Lio/getstream/chat/android/ui/common/helper/CopyToClipboardHandler;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/ui/common/helper/CopyToClipboardHandler;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addMembers (Ljava/util/Set;)V public final fun getEvents ()Lkotlinx/coroutines/flow/SharedFlow; public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; @@ -6915,8 +6930,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public static final field $stable I - public fun (Landroid/content/Context;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoNameField.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoNameField.kt deleted file mode 100644 index 108477a31ed..00000000000 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoNameField.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.chat.android.compose.ui.channel.info - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Done -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.compose.ui.theme.animation.FadingVisibility -import io.getstream.chat.android.compose.ui.util.clickable - -@Suppress("LongMethod") -@Composable -internal fun ChannelInfoNameField( - name: String, - readOnly: Boolean, - onConfirmRenaming: (name: String) -> Unit, -) { - val focusManager = LocalFocusManager.current - var value by remember { mutableStateOf(name) } - var showEditingButtons by remember { mutableStateOf(false) } - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { showEditingButtons = it.isFocused && !readOnly }, - readOnly = readOnly, - textStyle = ChatTheme.typography.bodyDefault, - prefix = { - Text( - modifier = Modifier.padding(end = 8.dp), - text = stringResource(R.string.stream_ui_channel_info_name_field_label), - style = ChatTheme.typography.bodyEmphasis, - color = ChatTheme.colors.textSecondary, - ) - }, - placeholder = { - Text( - text = stringResource(R.string.stream_ui_channel_info_name_field_placeholder), - style = ChatTheme.typography.bodyDefault, - ) - }, - value = value, - trailingIcon = { - FadingVisibility(visible = showEditingButtons) { - Row( - modifier = Modifier.padding(end = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier.clickable(bounded = false) { - value = name - focusManager.clearFocus() - }, - imageVector = Icons.Rounded.Close, - tint = ChatTheme.colors.textSecondary, - contentDescription = null, - ) - Icon( - modifier = Modifier.clickable(bounded = false) { - onConfirmRenaming(value) - focusManager.clearFocus() - }, - imageVector = Icons.Rounded.Done, - tint = ChatTheme.colors.accentPrimary, - contentDescription = null, - ) - } - } - }, - singleLine = true, - onValueChange = { value = it }, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = ChatTheme.colors.textPrimary, - unfocusedTextColor = ChatTheme.colors.textPrimary, - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) -} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoOptionItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoOptionItem.kt index 57fee0e4ce1..2f332077246 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoOptionItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoOptionItem.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.compose.ui.channel.info -import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -32,7 +31,7 @@ import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewSta @Suppress("LongMethod") @Composable -internal fun LazyItemScope.ChannelInfoOptionItem( +internal fun ChannelInfoOptionItem( option: ChannelInfoViewState.Content.Option, isGroupChannel: Boolean, onViewAction: (action: ChannelInfoViewAction) -> Unit, @@ -53,25 +52,11 @@ internal fun ChannelInfoOptionContent( ) { when (option) { is ChannelInfoViewState.Content.Option.AddMember -> { - // Not rendered as an option, but as a button in the top bar + // Not rendered as an option, but as a button in the members section } - is ChannelInfoViewState.Content.Option.UserInfo -> { - val user = option.user - ChannelInfoUserInfoOption( - username = user.name.takeIf(String::isNotBlank) ?: user.id, - onClick = { onViewAction(ChannelInfoViewAction.UserInfoClick(user)) }, - ) - } - - is ChannelInfoViewState.Content.Option.RenameChannel -> { - ChannelInfoNameField( - name = option.name, - readOnly = option.isReadOnly, - onConfirmRenaming = { name -> - onViewAction(ChannelInfoViewAction.RenameChannelClick(name)) - }, - ) + is ChannelInfoViewState.Content.Option.EditChannel -> { + // Not rendered as an option, but as an action button in the top bar } is ChannelInfoViewState.Content.Option.MuteChannel -> { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoUserInfoOption.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoUserInfoOption.kt deleted file mode 100644 index 1862c9ddec7..00000000000 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoUserInfoOption.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.chat.android.compose.ui.channel.info - -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.theme.ChatTheme - -@Composable -internal fun ChannelInfoUserInfoOption( - username: String, - onClick: () -> Unit, -) { - ChannelInfoOption(onClick = onClick) { - Icon( - painter = painterResource(R.drawable.stream_compose_ic_person), - contentDescription = null, - tint = ChatTheme.colors.textSecondary, - ) - Text( - modifier = Modifier.weight(1f), - text = "@$username", - style = ChatTheme.typography.bodyEmphasis, - color = ChatTheme.colors.textPrimary, - ) - Icon( - painter = painterResource(R.drawable.stream_compose_ic_copy), - contentDescription = stringResource(R.string.stream_ui_channel_info_copy_user_handle), - tint = ChatTheme.colors.textSecondary, - ) - } -} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelEditScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelEditScreen.kt new file mode 100644 index 00000000000..9aa8b90a9e7 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelEditScreen.kt @@ -0,0 +1,484 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.channel.info + +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.LoadingIndicator +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.components.button.StreamButton +import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults +import io.getstream.chat.android.compose.ui.components.button.StreamTextButton +import io.getstream.chat.android.compose.ui.messages.attachments.media.rememberCaptureMediaLauncher +import io.getstream.chat.android.compose.ui.theme.ChannelAvatarParams +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.ViewModelStore +import io.getstream.chat.android.compose.ui.util.bottomBorder +import io.getstream.chat.android.compose.viewmodel.channel.GroupChannelEditViewEvent +import io.getstream.chat.android.compose.viewmodel.channel.GroupChannelEditViewModel +import io.getstream.chat.android.compose.viewmodel.channel.GroupChannelEditViewModelFactory +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.previewdata.PreviewChannelData +import io.getstream.chat.android.ui.common.contract.internal.CaptureMediaContract +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun GroupChannelEditScreen( + channel: Channel, + onDismiss: () -> Unit = {}, +) { + val context = LocalContext.current + val viewModel = viewModel( + factory = GroupChannelEditViewModelFactory(context = context, cid = channel.cid), + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + var channelName by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(text = channel.name, selection = TextRange(channel.name.length))) + } + var showImagePicker by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + when (event) { + GroupChannelEditViewEvent.SaveSuccess -> onDismiss() + GroupChannelEditViewEvent.SaveError -> { + Toast.makeText( + context, + context.getString(R.string.stream_ui_channel_info_edit_save_error), + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + + val pendingImageFile = state.pendingImageFile + val removeImage = state.removeImage + val displayChannel = remember(channel, pendingImageFile, removeImage) { + when { + pendingImageFile != null -> channel.copy(image = Uri.fromFile(pendingImageFile).toString()) + removeImage -> channel.copy(image = "") + else -> channel + } + } + + GroupChannelEditContent( + channel = displayChannel, + channelName = channelName, + isBusy = state.isBusy, + onChannelNameChange = { channelName = it }, + onNavigationIconClick = onDismiss, + onSaveActionClick = { viewModel.save(channelName.text) }, + onUploadPictureClick = { showImagePicker = true }, + ) + + ImagePickerSheet( + visible = showImagePicker, + showRemoveOption = (channel.image.isNotBlank() || pendingImageFile != null) && !removeImage, + onDismiss = { showImagePicker = false }, + onGalleryUriPicked = viewModel::importGalleryImage, + onImageSelected = viewModel::setPendingImage, + onImageRemoved = viewModel::removeImage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ImagePickerSheet( + visible: Boolean, + showRemoveOption: Boolean, + onDismiss: () -> Unit = {}, + onGalleryUriPicked: (Uri) -> Unit = {}, + onImageSelected: (File) -> Unit = {}, + onImageRemoved: () -> Unit = {}, +) { + val pickMediaLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri -> + uri?.let(onGalleryUriPicked) + } + + val capturePhotoLauncher = rememberCaptureMediaLauncher( + mode = CaptureMediaContract.Mode.PHOTO, + onResult = onImageSelected, + ) + + val previewMode = LocalInspectionMode.current + val sheetState = rememberModalBottomSheetState() + LaunchedEffect(previewMode) { + if (previewMode) sheetState.show() + } + + if (visible) { + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + containerColor = ChatTheme.colors.backgroundCoreApp, + ) { + ImagePickerOptions( + showRemoveOption = showRemoveOption, + onChooseFromLibraryClick = { + onDismiss() + pickMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onTakePhotoClick = { + onDismiss() + capturePhotoLauncher.launch(Unit) + }, + onRemovePictureClick = { + onDismiss() + onImageRemoved() + }, + ) + } + } +} + +@Composable +private fun GroupChannelEditContent( + channel: Channel, + channelName: TextFieldValue, + isBusy: Boolean = false, + onChannelNameChange: (TextFieldValue) -> Unit = {}, + onNavigationIconClick: () -> Unit = {}, + onSaveActionClick: () -> Unit = {}, + onUploadPictureClick: () -> Unit = {}, +) { + Scaffold( + topBar = { + GroupChannelEditTopBar( + isBusy = isBusy, + onNavigationIconClick = onNavigationIconClick, + onSaveActionClick = onSaveActionClick, + ) + }, + containerColor = ChatTheme.colors.backgroundCoreApp, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxWidth() + .padding(horizontal = StreamTokens.spacingMd, vertical = StreamTokens.spacing2xl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ChatTheme.componentFactory.ChannelAvatar( + params = ChannelAvatarParams( + modifier = Modifier.size(AvatarSize.ExtraExtraLarge), + channel = channel, + ), + ) + Spacer(modifier = Modifier.size(StreamTokens.spacingXs)) + StreamTextButton( + onClick = onUploadPictureClick, + text = stringResource(R.string.stream_ui_channel_info_edit_upload_picture), + style = StreamButtonStyleDefaults.primaryGhost, + enabled = !isBusy, + ) + Spacer(modifier = Modifier.size(StreamTokens.spacing2xl)) + ChannelNameField( + value = channelName, + enabled = !isBusy, + onValueChange = onChannelNameChange, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun GroupChannelEditTopBar( + isBusy: Boolean, + onNavigationIconClick: () -> Unit, + onSaveActionClick: () -> Unit, +) { + val colors = ChatTheme.colors + CenterAlignedTopAppBar( + modifier = Modifier.bottomBorder(color = colors.borderCoreSubtle), + title = { + Text( + text = stringResource(R.string.stream_ui_channel_info_edit_title), + style = ChatTheme.typography.headingMedium, + maxLines = 1, + ) + }, + navigationIcon = { ChannelInfoNavigationIcon(onClick = onNavigationIconClick) }, + actions = { + if (isBusy) { + LoadingIndicator( + modifier = Modifier + .padding(end = StreamTokens.spacingSm) + .size(24.dp), + ) + } else { + StreamButton( + style = StreamButtonStyleDefaults.primarySolid, + onClick = onSaveActionClick, + ) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_checkmark), + contentDescription = stringResource(id = R.string.stream_ui_channel_info_edit_save_action), + ) + } + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = colors.backgroundCoreApp, + scrolledContainerColor = colors.backgroundCoreApp, + titleContentColor = colors.textPrimary, + navigationIconContentColor = colors.textPrimary, + actionIconContentColor = colors.textPrimary, + ), + ) +} + +@Composable +private fun ImagePickerOptions( + showRemoveOption: Boolean, + onChooseFromLibraryClick: () -> Unit = {}, + onTakePhotoClick: () -> Unit = {}, + onRemovePictureClick: () -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = StreamTokens.spacingMd), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.stream_ui_channel_info_edit_group_picture), + style = ChatTheme.typography.headingSmall, + color = ChatTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.size(StreamTokens.spacingSm)) + PickerOptionButton( + modifier = Modifier.fillMaxWidth(), + iconRes = R.drawable.stream_compose_ic_attachment_camera_picker, + textRes = R.string.stream_ui_channel_info_edit_take_photo, + onClick = onTakePhotoClick, + ) + PickerOptionButton( + modifier = Modifier.fillMaxWidth(), + iconRes = R.drawable.stream_compose_ic_media, + textRes = R.string.stream_ui_channel_info_edit_choose_from_library, + onClick = onChooseFromLibraryClick, + ) + if (showRemoveOption) { + PickerOptionButton( + modifier = Modifier.fillMaxWidth(), + destructive = true, + iconRes = R.drawable.stream_ic_action_delete, + textRes = R.string.stream_ui_channel_info_edit_remove_picture, + onClick = onRemovePictureClick, + ) + } + } +} + +@Composable +private fun PickerOptionButton( + @DrawableRes iconRes: Int, + @StringRes textRes: Int, + modifier: Modifier = Modifier, + destructive: Boolean = false, + onClick: () -> Unit = {}, +) { + ChannelInfoOption( + modifier = modifier, + onClick = onClick, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = if (destructive) ChatTheme.colors.buttonDestructiveText else ChatTheme.colors.textSecondary, + ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(textRes), + style = ChatTheme.typography.bodyDefault, + color = if (destructive) ChatTheme.colors.buttonDestructiveText else ChatTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ChannelNameField( + value: TextFieldValue, + enabled: Boolean, + onValueChange: (TextFieldValue) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + val colors = ChatTheme.colors + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + textStyle = ChatTheme.typography.bodyDefault, + placeholder = { + Text( + text = stringResource(R.string.stream_ui_channel_info_edit_name_field_placeholder), + style = ChatTheme.typography.bodyDefault, + ) + }, + value = value, + singleLine = true, + enabled = enabled, + onValueChange = onValueChange, + shape = RoundedCornerShape(StreamTokens.radiusLg), + colors = OutlinedTextFieldDefaults.colors( + cursorColor = colors.systemCaret, + focusedBorderColor = colors.borderUtilityActive, + focusedTextColor = colors.inputTextDefault, + unfocusedBorderColor = colors.borderCoreDefault, + unfocusedTextColor = colors.inputTextDefault, + disabledTextColor = colors.textDisabled, + disabledBorderColor = colors.borderUtilityDisabled, + ), + ) +} + +@Preview +@Composable +private fun GroupChannelEditPlaceholderPreview() { + ChatTheme { + ViewModelStore { + GroupChannelEditPlaceholder() + } + } +} + +@Composable +internal fun GroupChannelEditPlaceholder() { + GroupChannelEditScreen( + channel = PreviewChannelData.channelWithImage, + ) +} + +@Preview +@Composable +private fun GroupChannelEditFilledPreview() { + ChatTheme { + GroupChannelEditFilled() + } +} + +@Composable +internal fun GroupChannelEditFilled() { + GroupChannelEditContent( + channel = PreviewChannelData.channelWithImage.copy(name = "Channel Name"), + channelName = TextFieldValue(text = "Channel Name"), + ) +} + +@Preview +@Composable +private fun GroupChannelEditBusyPreview() { + ChatTheme { + GroupChannelEditBusy() + } +} + +@Composable +internal fun GroupChannelEditBusy() { + GroupChannelEditContent( + channel = PreviewChannelData.channelWithImage.copy(name = "Channel Name"), + channelName = TextFieldValue(text = "Channel Name"), + isBusy = true, + ) +} + +@Preview +@Composable +private fun ImagePickerOptionsPreview() { + ChatTheme { + ImagePickerOptionsWithRemove() + } +} + +@Composable +internal fun ImagePickerOptionsWithRemove() { + ImagePickerSheet( + visible = true, + showRemoveOption = true, + ) +} + +@Preview +@Composable +private fun ImagePickerOptionsNoRemovePreview() { + ChatTheme { + ImagePickerOptionsNoRemove() + } +} + +@Composable +internal fun ImagePickerOptionsNoRemove() { + ImagePickerSheet( + visible = true, + showRemoveOption = false, + ) +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt index cb5252a507c..1ca1e82743c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,8 +59,11 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.ContentBox import io.getstream.chat.android.compose.ui.components.FullscreenDialog import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults +import io.getstream.chat.android.compose.ui.components.button.StreamTextButton import io.getstream.chat.android.compose.ui.theme.ChannelAvatarParams import io.getstream.chat.android.compose.ui.theme.ChannelInfoScreenModalParams +import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.GroupChannelInfoAddMembersButtonParams import io.getstream.chat.android.compose.ui.theme.GroupChannelInfoAvatarContainerParams @@ -112,8 +116,8 @@ public fun GroupChannelInfoScreen( val infoViewModel = viewModel(factory = viewModelFactory) val headerState by headerViewModel.state.collectAsStateWithLifecycle() val infoState by infoViewModel.state.collectAsStateWithLifecycle() - - var showAddMembers by remember { mutableStateOf(false) } + var showEditChannel by rememberSaveable { mutableStateOf(false) } + var showAddMembers by rememberSaveable { mutableStateOf(false) } GroupChannelInfoScaffold( modifier = modifier, @@ -121,11 +125,23 @@ public fun GroupChannelInfoScreen( headerState = headerState, infoState = infoState, onNavigationIconClick = onNavigationIconClick, + onActionClick = { showEditChannel = true }, onAddMembersClick = { showAddMembers = true }, onViewAction = infoViewModel::onViewAction, ) - GroupChannelInfoScreenModal(infoViewModel) + val channel = (headerState as? ChannelHeaderViewState.Content)?.channel + if (showEditChannel && channel != null) { + FullscreenDialog(onDismissRequest = { showEditChannel = false }) { + ViewModelStore { + GroupChannelEditScreen( + channel = channel, + onDismiss = { showEditChannel = false }, + ) + } + } + } + if (showAddMembers) { FullscreenDialog(onDismissRequest = { showAddMembers = false }) { ViewModelStore { @@ -141,6 +157,8 @@ public fun GroupChannelInfoScreen( } } } + + GroupChannelInfoScreenModal(infoViewModel) } @Composable @@ -150,6 +168,7 @@ private fun GroupChannelInfoScaffold( headerState: ChannelHeaderViewState, infoState: ChannelInfoViewState, onNavigationIconClick: () -> Unit = {}, + onActionClick: () -> Unit = {}, onAddMembersClick: () -> Unit = {}, onViewAction: (action: ChannelInfoViewAction) -> Unit = {}, ) { @@ -163,7 +182,7 @@ private fun GroupChannelInfoScaffold( infoState = infoState, listState = listState, onNavigationIconClick = onNavigationIconClick, - onAddMembersClick = onAddMembersClick, + onActionClick = onActionClick, ), ) }, @@ -211,7 +230,7 @@ internal fun GroupChannelInfoTopBar( infoState: ChannelInfoViewState, listState: LazyListState, onNavigationIconClick: () -> Unit, - onAddMembersClick: () -> Unit, + onActionClick: () -> Unit, ) { CenterAlignedTopAppBar( modifier = Modifier.bottomBorder(color = ChatTheme.colors.borderCoreSubtle), @@ -227,7 +246,17 @@ internal fun GroupChannelInfoTopBar( onClick = onNavigationIconClick, ) }, - actions = { /* No trailing action icon */ }, + actions = { + if (infoState is ChannelInfoViewState.Content && + infoState.options.any { option -> option is ChannelInfoViewState.Content.Option.EditChannel } + ) { + StreamTextButton( + style = StreamButtonStyleDefaults.secondaryOutline, + text = stringResource(id = R.string.stream_ui_channel_info_edit_action), + onClick = onActionClick, + ) + } + }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = ChatTheme.colors.backgroundCoreApp, scrolledContainerColor = ChatTheme.colors.backgroundCoreApp, @@ -529,18 +558,18 @@ internal fun GroupChannelInfoExpandMembersItem( @Preview(showBackground = true) @Composable private fun GroupChannelInfoLoadingPreview() { - ChatTheme { + ChatPreviewTheme { GroupChannelInfoLoading() } } @Composable internal fun GroupChannelInfoLoading() { - GroupChannelInfoScaffold( - modifier = Modifier.fillMaxSize(), + GroupChannelInfoScreen( + viewModelFactory = ChannelInfoViewModelFactory( + cid = PreviewChannelData.channelWithImage.cid, + ), currentUser = PreviewUserData.user1, - headerState = ChannelHeaderViewState.Loading, - infoState = ChannelInfoViewState.Loading, ) } @@ -554,13 +583,14 @@ private fun GroupChannelInfoCollapsedMembersPreview() { @Composable internal fun GroupChannelInfoCollapsedMembers() { + val channel = PreviewChannelData.channelWithImage.copy(name = "Channel Name") GroupChannelInfoScaffold( modifier = Modifier.fillMaxSize(), currentUser = PreviewUserData.user1, headerState = ChannelHeaderViewState.Content( currentUser = PreviewUserData.user1, connectionState = ConnectionState.Connected, - channel = PreviewChannelData.channelWithImage, + channel = channel, ), infoState = ChannelInfoViewState.Content( owner = PreviewUserData.user1, @@ -576,6 +606,7 @@ internal fun GroupChannelInfoCollapsedMembers() { ), options = listOf( ChannelInfoViewState.Content.Option.AddMember, + ChannelInfoViewState.Content.Option.EditChannel(name = channel.name), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/ChannelAvatar.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/ChannelAvatar.kt index 81bd1dac91b..f1353af308b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/ChannelAvatar.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/ChannelAvatar.kt @@ -59,6 +59,7 @@ import io.getstream.chat.android.ui.common.utils.extensions.isOneToOne * * @param channel The channel whose avatar will be displayed. * @param currentUser The user currently logged in. + * @param modifier The modifier to be applied to this layout. * @param showIndicator Whether to overlay a status indicator to show whether the user is online for 1:1 channels. * @param showBorder Whether to draw a border around the avatar to provide contrast against the background. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 2617e162cdc..2f6883e063c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -2500,7 +2500,7 @@ public interface ChatComponentFactory { infoState = params.infoState, listState = params.listState, onNavigationIconClick = params.onNavigationIconClick, - onAddMembersClick = params.onAddMembersClick, + onActionClick = params.onActionClick, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index b5731818573..3b3f67b3e38 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -1825,15 +1825,15 @@ public data class GroupChannelInfoMemberSectionParams( * @param headerState The state of the channel header. * @param infoState The state of the channel info. * @param listState The state of the lazy list. - * @param onNavigationIconClick Action invoked when the navigation icon is clicked. - * @param onAddMembersClick Action invoked when the "Add members" button is clicked. + * @param onNavigationIconClick Callback invoked when the navigation icon is clicked. + * @param onActionClick Callback invoked when the action button is clicked. */ public data class GroupChannelInfoTopBarParams( val headerState: ChannelHeaderViewState, val infoState: ChannelInfoViewState, val listState: LazyListState, val onNavigationIconClick: () -> Unit, - val onAddMembersClick: () -> Unit, + val onActionClick: () -> Unit = {}, ) /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDesign.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDesign.kt index 0839d24b6c4..98e3e28f776 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDesign.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDesign.kt @@ -118,6 +118,7 @@ public object StreamDesign { * surface behind the avatar. * @param skeletonLoadingBase Base color for the skeleton loading gradient (placeholder surfaces). * @param skeletonLoadingHighlight Highlight for the skeleton loading gradient (moving shimmer). + * @param systemCaret System caret color. */ @Immutable public data class Colors( @@ -183,6 +184,7 @@ public object StreamDesign { public val avatarPresenceBorder: Color, public val skeletonLoadingBase: Color, public val skeletonLoadingHighlight: Color, + public val systemCaret: Color, ) { /** Default badge background. */ @@ -527,6 +529,7 @@ public object StreamDesign { avatarPresenceBorder = chrome.s0, skeletonLoadingBase = Color.Transparent, skeletonLoadingHighlight = Color(0xBFFFFFFF), + systemCaret = brand.s500, ) } @@ -604,6 +607,7 @@ public object StreamDesign { avatarPresenceBorder = chrome.s0, skeletonLoadingBase = Color.Transparent, skeletonLoadingHighlight = Color(0xBF000000), + systemCaret = brand.s400, ) } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt index 1bdf11e48e4..b797e99906e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt @@ -25,7 +25,6 @@ import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoMembe import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewAction import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewController import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewEvent -import io.getstream.chat.android.ui.common.helper.CopyToClipboardHandler import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -34,20 +33,17 @@ import kotlinx.coroutines.flow.StateFlow * ViewModel for managing channel information and its actions. * * @param cid The full channel identifier (e.g., "messaging:123"). - * @param copyToClipboardHandler The handler for copying text to the clipboard. * @param optionFilter A filter function for channel options, allowing customization of which options are displayed. * Defaults to a function that returns true for all options. * @param controllerProvider The provider for [ChannelInfoViewController]. */ public class ChannelInfoViewModel( private val cid: String, - private val copyToClipboardHandler: CopyToClipboardHandler, private val optionFilter: (option: ChannelInfoViewState.Content.Option) -> Boolean = { true }, private val controllerProvider: ViewModel.() -> ChannelInfoViewController = { ChannelInfoViewController( cid = cid, scope = viewModelScope, - copyToClipboardHandler = copyToClipboardHandler, optionFilter = optionFilter, ) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt index 04e768347a7..5704ddd64cd 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt @@ -16,22 +16,18 @@ package io.getstream.chat.android.compose.viewmodel.channel -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import io.getstream.chat.android.ui.common.helper.CopyToClipboardHandler import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState /** * Factory for creating instances of [ChannelInfoViewModel]. * - * @param context The application context. * @param cid The full channel identifier (Ex. "messaging:123"). * @param optionFilter A filter function for channel options, allowing customization of which options are displayed. * Defaults to a function that returns true for all options. */ public class ChannelInfoViewModelFactory( - private val context: Context, private val cid: String, private val optionFilter: (option: ChannelInfoViewState.Content.Option) -> Boolean = { true }, ) : ViewModelProvider.Factory { @@ -41,7 +37,6 @@ public class ChannelInfoViewModelFactory( ChannelInfoViewModel::class.java to { ChannelInfoViewModel( cid = cid, - copyToClipboardHandler = CopyToClipboardHandler(context = context.applicationContext), optionFilter = optionFilter, ) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/GalleryImageCopier.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/GalleryImageCopier.kt new file mode 100644 index 00000000000..84c2742fc00 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/GalleryImageCopier.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.viewmodel.channel + +import android.content.Context +import android.net.Uri +import io.getstream.chat.android.client.internal.file.StreamFileManager +import java.io.File +import java.util.UUID + +/** + * Copies a picked gallery or document [Uri] into app cache as a local image [File]. + */ +internal fun interface GalleryImageCopier { + /** + * @param uri The content or file [Uri] to read. + * @return A file in app cache, or `null` if the [Uri] cannot be read or written. + */ + fun copyToCache(uri: Uri): File? +} + +/** + * [GalleryImageCopier] that reads the [Uri] via [android.content.ContentResolver] and writes an image + * into timestamped cache using [StreamFileManager]. + * + * @param context Used for [android.content.ContentResolver] and cache directory access. + * @param fileManager Writes the stream into cache. + */ +internal class ContentResolverImageCopier( + private val context: Context, + private val fileManager: StreamFileManager = StreamFileManager(), +) : GalleryImageCopier { + + override fun copyToCache(uri: Uri): File? { + val inputStream = context.contentResolver.openInputStream(uri) ?: return null + return fileManager.writeFileInTimestampedCache( + context = context, + fileName = "image_${UUID.randomUUID()}", + source = inputStream, + ).getOrNull() + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/GroupChannelEditViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/GroupChannelEditViewModel.kt new file mode 100644 index 00000000000..56d036215c1 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/GroupChannelEditViewModel.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.viewmodel.channel + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.internal.file.StreamFileManager +import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider +import io.getstream.log.taggedLogger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * ViewModel responsible for managing the group channel edit screen state and save operations. + * + * Handles copying a gallery [Uri] to cache, uploading a new channel image, and/or updating the + * channel name in a single save action. + * + * @param cid The full channel identifier (e.g., "messaging:123"). + * @param galleryImageCopier Copies a picked [Uri] to a local cache [File]; invoked on IO from + * [importGalleryImage]. + * @param chatClient The [ChatClient] instance used for API calls. + */ +internal class GroupChannelEditViewModel( + private val cid: String, + private val galleryImageCopier: GalleryImageCopier, + private val chatClient: ChatClient = ChatClient.instance(), +) : ViewModel() { + + private val logger by taggedLogger("Chat:GroupChannelEditVM") + private val channelClient = chatClient.channel(cid) + + private val _state = MutableStateFlow(GroupChannelEditViewState()) + + /** The current state of the edit screen. */ + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + + /** One-shot events emitted by the ViewModel. */ + val events: SharedFlow = _events.asSharedFlow() + + /** + * Copies a gallery [Uri] to app cache on a background thread. + * Sets [GroupChannelEditViewState.isImporting] for the duration of the copy; + * [save] is ignored while that flag is true. On success the resulting [File] is stored in + * [GroupChannelEditViewState.pendingImageFile]. + * + * @param uri The content [Uri] returned by the gallery picker. + */ + fun importGalleryImage(uri: Uri) { + if (_state.value.isImporting) return + _state.update { it.copy(isImporting = true) } + viewModelScope.launch { + val file = withContext(DispatcherProvider.IO) { galleryImageCopier.copyToCache(uri) } + _state.update { + it.copy( + isImporting = false, + pendingImageFile = file ?: it.pendingImageFile, + removeImage = if (file != null) false else it.removeImage, + ) + } + } + } + + /** + * Stores an image file as the pending channel avatar. + * + * @param file The local image file to use (e.g. from camera capture). + */ + fun setPendingImage(file: File) { + _state.update { it.copy(pendingImageFile = file, removeImage = false) } + } + + /** + * Marks the current channel image for removal and clears any pending image. + */ + fun removeImage() { + _state.update { it.copy(pendingImageFile = null, removeImage = true) } + } + + /** + * Persists channel edits to the backend. + * + * Reads [GroupChannelEditViewState.pendingImageFile] and [GroupChannelEditViewState.removeImage] + * from the current state snapshot. When a pending image exists it is uploaded first; when + * [GroupChannelEditViewState.removeImage] is true the channel image is cleared. Both name and + * image changes are sent in a single `updatePartial` call. + * + * Ignored while [GroupChannelEditViewState.isBusy] is true. + * + * @param name The new channel name. + */ + fun save(name: String) { + val trimmedName = name.trim() + val snapshot = _state.value + if (snapshot.isBusy) return + val imageFile = snapshot.pendingImageFile + val removeImage = snapshot.removeImage + logger.d { "[save] name: $trimmedName, imageFile: ${imageFile?.name}, removeImage: $removeImage" } + _state.update { it.copy(isSaving = true) } + viewModelScope.launch { + val imageUrl = resolveImageUrl(imageFile, removeImage) + if (imageUrl is ImageUrlResult.Error) { + emitSaveResult(GroupChannelEditViewEvent.SaveError) + return@launch + } + val updates = buildUpdates(trimmedName, imageUrl) + val result = channelClient.updatePartial(set = updates).await() + val event = if (result.isSuccess) { + GroupChannelEditViewEvent.SaveSuccess + } else { + logger.e { "[save] updatePartial failed: ${result.errorOrNull()?.message}" } + GroupChannelEditViewEvent.SaveError + } + emitSaveResult(event) + } + } + + private suspend fun resolveImageUrl(imageFile: File?, removeImage: Boolean): ImageUrlResult = when { + imageFile != null -> { + val result = chatClient.uploadImage(imageFile).await() + val url = result.getOrNull()?.file + if (url != null) { + ImageUrlResult.Url(url) + } else { + logger.e { "[resolveImageUrl] upload failed: ${result.errorOrNull()?.message}" } + ImageUrlResult.Error + } + } + removeImage -> ImageUrlResult.Url("") + else -> ImageUrlResult.NoChange + } + + private fun buildUpdates(name: String, imageUrl: ImageUrlResult): Map = buildMap { + put("name", name) + if (imageUrl is ImageUrlResult.Url) put("image", imageUrl.value) + } + + private fun emitSaveResult(event: GroupChannelEditViewEvent) { + _state.update { it.copy(isSaving = false) } + _events.tryEmit(event) + } +} + +private sealed interface ImageUrlResult { + data class Url(val value: String) : ImageUrlResult + data object NoChange : ImageUrlResult + data object Error : ImageUrlResult +} + +/** + * Represents the UI state of the group channel edit screen. + * + * @param isSaving Whether a save operation is currently in progress. + * @param isImporting Whether the picked image is being copied from the picker to app cache (see + * [GroupChannelEditViewModel.importGalleryImage]). + * @param pendingImageFile A locally cached image file selected as the new channel avatar, or `null` + * if no new image has been picked. + * @param removeImage Whether the current channel image should be removed on save. + */ +internal data class GroupChannelEditViewState( + val isSaving: Boolean = false, + val isImporting: Boolean = false, + val pendingImageFile: File? = null, + val removeImage: Boolean = false, +) { + /** True while the user cannot safely save (save in flight or image import in progress). */ + val isBusy: Boolean + get() = isSaving || isImporting +} + +/** One-shot events emitted by [GroupChannelEditViewModel]. */ +internal sealed interface GroupChannelEditViewEvent { + /** The save operation completed successfully. */ + data object SaveSuccess : GroupChannelEditViewEvent + + /** The save operation failed. */ + data object SaveError : GroupChannelEditViewEvent +} + +/** + * Factory for creating [GroupChannelEditViewModel] instances. + * + * @param context A [Context] used for gallery import. + * @param cid The full channel identifier (e.g., "messaging:123"). + * @param fileManager Optional cache writer; defaults to a new [StreamFileManager]. + */ +internal class GroupChannelEditViewModelFactory( + private val context: Context, + private val cid: String, + private val fileManager: StreamFileManager = StreamFileManager(), +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return GroupChannelEditViewModel( + cid = cid, + galleryImageCopier = ContentResolverImageCopier( + context = context.applicationContext, + fileManager = fileManager, + ), + ) as T + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/PaparazziComposeTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/PaparazziComposeTest.kt index 04bfa10f56e..d0405696a1b 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/PaparazziComposeTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/PaparazziComposeTest.kt @@ -32,6 +32,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.platform.LocalInspectionMode import androidx.core.app.ActivityOptionsCompat import androidx.lifecycle.Lifecycle @@ -53,13 +55,15 @@ internal interface PaparazziComposeTest : MockedChatClientTest { fun snapshot( isInDarkMode: Boolean = false, contentAlignment: Alignment = Alignment.TopStart, + backgroundColor: Color = Color.Unspecified, composable: @Composable () -> Unit, ) { paparazzi.snapshot { TestEnvironment { ChatTheme(isInDarkMode = isInDarkMode) { Box( - modifier = Modifier.background(ChatTheme.colors.backgroundCoreApp), + modifier = Modifier + .background(backgroundColor.takeOrElse(ChatTheme.colors::backgroundCoreApp)), contentAlignment = contentAlignment, ) { composable() diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channel/info/GroupChannelEditScreenTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channel/info/GroupChannelEditScreenTest.kt new file mode 100644 index 00000000000..e682492f8cb --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channel/info/GroupChannelEditScreenTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.channel.info + +import androidx.compose.ui.graphics.Color +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import io.getstream.chat.android.compose.ui.PaparazziComposeTest +import org.junit.Rule +import org.junit.Test + +internal class GroupChannelEditScreenTest : PaparazziComposeTest { + + @get:Rule + override val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_2) + + @Test + fun placeholder() { + snapshotWithDarkMode { + GroupChannelEditPlaceholder() + } + } + + @Test + fun filled() { + snapshotWithDarkMode { + GroupChannelEditFilled() + } + } + + @Test + fun busy() { + snapshotWithDarkMode { + GroupChannelEditBusy() + } + } + + @Test + fun `image picker with remove in light mode`() { + snapshot(backgroundColor = Color.Transparent) { + ImagePickerOptionsWithRemove() + } + } + + @Test + fun `image picker with remove in dark mode`() { + snapshot(backgroundColor = Color.Transparent, isInDarkMode = true) { + ImagePickerOptionsWithRemove() + } + } + + @Test + fun `image picker no remove in light mode`() { + snapshot(backgroundColor = Color.Transparent) { + ImagePickerOptionsNoRemove() + } + } + + @Test + fun `image picker no remove in dark mode`() { + snapshot(backgroundColor = Color.Transparent, isInDarkMode = true) { + ImagePickerOptionsNoRemove() + } + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt index b42758386a7..55cb34fe840 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt @@ -16,15 +16,12 @@ package io.getstream.chat.android.compose.viewmodel.channel -import android.content.Context import androidx.lifecycle.ViewModel import io.getstream.chat.android.randomCID import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test import org.junit.jupiter.api.assertInstanceOf -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock internal class ChannelInfoViewModelFactoryTest { @@ -71,12 +68,7 @@ internal class ChannelInfoViewModelFactoryTest { } private class Fixture { - private val mockContext: Context = mock { - on { applicationContext } doReturn it - } - fun get() = ChannelInfoViewModelFactory( - context = mockContext, cid = randomCID(), ) } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelTest.kt index 6b3e6237f75..a3669b5e51b 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelTest.kt @@ -19,7 +19,6 @@ package io.getstream.chat.android.compose.viewmodel.channel import app.cash.turbine.test import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomMember -import io.getstream.chat.android.randomUser import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoMemberViewEvent import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewAction import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewController @@ -72,7 +71,7 @@ internal class ChannelInfoViewModelTest { val fixture = Fixture() val sut = fixture.get() - val action = ChannelInfoViewAction.UserInfoClick(user = randomUser()) + val action = ChannelInfoViewAction.PinnedMessagesClick sut.onViewAction(action) fixture.verifyControllerOnViewAction(action) @@ -110,7 +109,6 @@ internal class ChannelInfoViewModelTest { fun get() = ChannelInfoViewModel( cid = randomCID(), - copyToClipboardHandler = mock(), optionFilter = mock(), controllerProvider = { mockController }, ) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ContentResolverImageCopierTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ContentResolverImageCopierTest.kt new file mode 100644 index 00000000000..f427375ffef --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ContentResolverImageCopierTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.viewmodel.channel + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.getstream.chat.android.randomString +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class ContentResolverImageCopierTest { + + private lateinit var context: Context + private lateinit var copier: ContentResolverImageCopier + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + copier = ContentResolverImageCopier(context) + } + + @Test + fun `copyToCache copies bytes from file uri into timestamped cache`() { + val bytes = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val source = File(context.cacheDir, "gallery_src_${randomString()}.jpg") + source.writeBytes(bytes) + val uri = Uri.fromFile(source) + + val result = copier.copyToCache(uri) + + assertNotNull(result) + assertTrue(result!!.exists()) + assertArrayEquals(bytes, result.readBytes()) + source.delete() + result.parentFile?.deleteRecursively() + } + + @Test + fun `copyToCache returns null when uri cannot be opened`() { + val result = copier.copyToCache(Uri.parse("content://${randomString()}/missing")) + assertNull(result) + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/GroupChannelEditViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/GroupChannelEditViewModelTest.kt new file mode 100644 index 00000000000..e5609c4563f --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/GroupChannelEditViewModelTest.kt @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.viewmodel.channel + +import android.net.Uri +import app.cash.turbine.test +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.UploadedFile +import io.getstream.chat.android.randomCID +import io.getstream.chat.android.randomString +import io.getstream.chat.android.test.asCall +import io.getstream.result.Error +import io.getstream.result.Result +import io.getstream.result.call.CoroutineCall +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalCoroutinesApi::class) +internal class GroupChannelEditViewModelTest { + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state is idle with no pending changes`() = runTest { + val sut = Fixture().get() + + sut.state.test { + val state = awaitItem() + assertFalse(state.isSaving) + assertFalse(state.isImporting) + assertFalse(state.isBusy) + assertNull(state.pendingImageFile) + assertFalse(state.removeImage) + } + } + + @Test + fun `gallery import toggles importing and busy flags`() = runTest { + val sut = Fixture().get() + + sut.state.test { + awaitItem() + sut.importGalleryImage(Uri.parse("content://test")) + val importing = awaitItem() + assertTrue(importing.isImporting) + assertTrue(importing.isBusy) + val idle = awaitItem() + assertFalse(idle.isImporting) + assertFalse(idle.isBusy) + } + } + + @Test + fun `gallery import stores pending image file in state`() = runTest { + val sut = Fixture().get() + + sut.state.test { + awaitItem() + sut.importGalleryImage(Uri.parse("content://test")) + skipItems(1) // importing + val done = awaitItem() + assertTrue(done.pendingImageFile?.exists() == true) + assertFalse(done.removeImage) + } + } + + @Test + fun `setPendingImage stores file and clears removeImage`() = runTest { + val file = File.createTempFile("camera", ".jpg").also { it.deleteOnExit() } + val sut = Fixture().get() + + sut.removeImage() + sut.setPendingImage(file) + + sut.state.test { + val state = awaitItem() + assertEquals(file, state.pendingImageFile) + assertFalse(state.removeImage) + } + } + + @Test + fun `removeImage clears pending file and sets flag`() = runTest { + val file = File.createTempFile("camera", ".jpg").also { it.deleteOnExit() } + val sut = Fixture().get() + + sut.setPendingImage(file) + sut.removeImage() + + sut.state.test { + val state = awaitItem() + assertNull(state.pendingImageFile) + assertTrue(state.removeImage) + } + } + + @Test + fun `duplicate gallery import is ignored while already importing`() = runTest { + val copyEntered = CountDownLatch(1) + val unblockCopy = CountDownLatch(1) + val copyCount = java.util.concurrent.atomic.AtomicInteger(0) + val blockingCopier = GalleryImageCopier { + copyCount.incrementAndGet() + copyEntered.countDown() + unblockCopy.await() + File.createTempFile("gallery", ".jpg").also { it.deleteOnExit() } + } + val sut = Fixture(galleryImageCopier = blockingCopier).get() + + sut.importGalleryImage(Uri.parse("content://first")) + assertTrue(copyEntered.await(5, TimeUnit.SECONDS)) + + sut.importGalleryImage(Uri.parse("content://second")) + unblockCopy.countDown() + + assertEquals(1, copyCount.get()) + } + + @Test + fun `gallery import failure preserves existing pending image`() = runTest { + val existingFile = File.createTempFile("existing", ".jpg").also { it.deleteOnExit() } + val failingCopier = GalleryImageCopier { null } + val sut = Fixture(galleryImageCopier = failingCopier).get() + + sut.setPendingImage(existingFile) + sut.importGalleryImage(Uri.parse("content://bad")) + + sut.state.test { + val state = awaitItem() + assertEquals(existingFile, state.pendingImageFile) + assertFalse(state.removeImage) + } + } + + @Test + fun `save is ignored while gallery import is in progress`() = runTest { + val copyEntered = CountDownLatch(1) + val unblockCopy = CountDownLatch(1) + val blockingCopier = GalleryImageCopier { + copyEntered.countDown() + unblockCopy.await() + File.createTempFile("gallery", ".jpg").also { it.deleteOnExit() } + } + val fixture = Fixture(galleryImageCopier = blockingCopier).givenUpdatePartial() + val sut = fixture.get() + sut.importGalleryImage(Uri.parse("content://test")) + + assertTrue(copyEntered.await(5, TimeUnit.SECONDS)) + + sut.events.test { + sut.save(name = randomString()) + expectNoEvents() + } + unblockCopy.countDown() + fixture.verifyUpdatePartialNeverCalled() + } + + @Test + fun `save with name only emits success`() = runTest { + val name = randomString() + val fixture = Fixture().givenUpdatePartial() + val sut = fixture.get() + + sut.events.test { + sut.save(name = " $name ") + assertEquals(GroupChannelEditViewEvent.SaveSuccess, awaitItem()) + } + fixture.verifyUpdatePartial(mapOf("name" to name)) + } + + @Test + fun `save with name only does not call uploadImage`() = runTest { + val fixture = Fixture().givenUpdatePartial() + val sut = fixture.get() + + sut.events.test { + sut.save(name = randomString()) + awaitItem() + } + fixture.verifyUploadImageNeverCalled() + } + + @Test + fun `save with pending image uploads then updates channel`() = runTest { + val name = randomString() + val imageUrl = "https://cdn.example.com/uploaded.jpg" + val imageFile = File.createTempFile("test", ".jpg") + val fixture = Fixture() + .givenUploadImage(imageFile, imageUrl) + .givenUpdatePartial() + val sut = fixture.get() + + sut.setPendingImage(imageFile) + sut.events.test { + sut.save(name = name) + assertEquals(GroupChannelEditViewEvent.SaveSuccess, awaitItem()) + } + fixture.verifyUpdatePartial(mapOf("name" to name, "image" to imageUrl)) + imageFile.delete() + } + + @Test + fun `save with removeImage sets image to empty string`() = runTest { + val name = randomString() + val fixture = Fixture().givenUpdatePartial() + val sut = fixture.get() + + sut.removeImage() + sut.events.test { + sut.save(name = name) + assertEquals(GroupChannelEditViewEvent.SaveSuccess, awaitItem()) + } + fixture.verifyUpdatePartial(mapOf("name" to name, "image" to "")) + } + + @Test + fun `save emits error when upload fails`() = runTest { + val imageFile = File.createTempFile("test", ".jpg") + val fixture = Fixture().givenUploadImageError(imageFile) + val sut = fixture.get() + + sut.setPendingImage(imageFile) + sut.events.test { + sut.save(name = randomString()) + assertEquals(GroupChannelEditViewEvent.SaveError, awaitItem()) + } + fixture.verifyUpdatePartialNeverCalled() + imageFile.delete() + } + + @Test + fun `save emits error when updatePartial fails`() = runTest { + val fixture = Fixture().givenUpdatePartialError() + val sut = fixture.get() + + sut.events.test { + sut.save(name = randomString()) + assertEquals(GroupChannelEditViewEvent.SaveError, awaitItem()) + } + } + + @Test + fun `isSaving is true during save and false after`() = runTest { + val fixture = Fixture().givenUpdatePartial() + val sut = fixture.get() + + sut.state.test { + assertFalse(awaitItem().isSaving) // initial + sut.save(name = randomString()) + assertTrue(awaitItem().isSaving) // saving + assertFalse(awaitItem().isSaving) // done + } + } + + @Test + fun `isSaving resets to false after upload error`() = runTest { + val imageFile = File.createTempFile("test", ".jpg") + val fixture = Fixture().givenUploadImageError(imageFile) + val sut = fixture.get() + + sut.setPendingImage(imageFile) + sut.state.test { + skipItems(1) // initial + setPendingImage (conflated) + sut.save(name = randomString()) + assertTrue(awaitItem().isSaving) + assertFalse(awaitItem().isSaving) + } + imageFile.delete() + } + + @Test + fun `duplicate save is ignored while saving`() = runTest { + val fixture = Fixture().givenUpdatePartialDelayed(this) + val sut = fixture.get() + + sut.events.test { + sut.save(name = "first") + sut.save(name = "second") + assertEquals(GroupChannelEditViewEvent.SaveSuccess, awaitItem()) + expectNoEvents() + } + fixture.verifyUpdatePartialCallCount(1) + } + + private class Fixture( + private val galleryImageCopier: GalleryImageCopier = GalleryImageCopier { + File.createTempFile("gallery", ".jpg").also { it.deleteOnExit() } + }, + ) { + private val channelClient: ChannelClient = mock() + private val chatClient: ChatClient = mock { + on { channel(any()) } doReturn channelClient + } + + fun givenUploadImage(file: File, url: String) = apply { + whenever(chatClient.uploadImage(file, null)) doReturn UploadedFile(file = url).asCall() + } + + fun givenUploadImageError(file: File) = apply { + val error = Error.GenericError(message = "Upload failed") + whenever(chatClient.uploadImage(file, null)) doReturn error.asCall() + } + + fun givenUpdatePartial() = apply { + whenever(channelClient.updatePartial(any(), any())) doReturn mock().asCall() + } + + fun givenUpdatePartialDelayed(scope: CoroutineScope) = apply { + whenever(channelClient.updatePartial(any(), any())) doReturn CoroutineCall(scope) { + delay(100) + Result.Success(mock()) + } + } + + fun givenUpdatePartialError() = apply { + val error = Error.GenericError(message = "Update failed") + whenever(channelClient.updatePartial(any(), any())) doReturn error.asCall() + } + + fun verifyUpdatePartial(expected: Map) { + verify(channelClient).updatePartial(set = expected, unset = emptyList()) + } + + fun verifyUpdatePartialNeverCalled() { + verify(channelClient, never()).updatePartial(any(), any()) + } + + fun verifyUpdatePartialCallCount(count: Int) { + verify(channelClient, times(count)).updatePartial(any(), any()) + } + + fun verifyUploadImageNeverCalled() { + verify(chatClient, never()).uploadImage(any(), any()) + } + + fun get() = GroupChannelEditViewModel( + cid = randomCID(), + galleryImageCopier = galleryImageCopier, + chatClient = chatClient, + ) + } +} diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_busy.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_busy.png new file mode 100644 index 00000000000..743e89294e6 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_busy.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_filled.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_filled.png new file mode 100644 index 00000000000..1cc8b277449 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_filled.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_no_remove_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_no_remove_in_dark_mode.png new file mode 100644 index 00000000000..d08f9a221be Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_no_remove_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_no_remove_in_light_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_no_remove_in_light_mode.png new file mode 100644 index 00000000000..8060c35c2d3 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_no_remove_in_light_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_with_remove_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_with_remove_in_dark_mode.png new file mode 100644 index 00000000000..b0f9de20532 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_with_remove_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_with_remove_in_light_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_with_remove_in_light_mode.png new file mode 100644 index 00000000000..f1b020de4ab Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_image_picker_with_remove_in_light_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_placeholder.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_placeholder.png new file mode 100644 index 00000000000..155521dcd55 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelEditScreenTest_placeholder.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members.png index 9c5c0f4c6a4..1a8f522ba31 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members_in_dark_mode.png index 4b605ea5d41..2ddd732a8b2 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_GroupChannelInfoContentTest_collapsed_members_in_dark_mode.png differ diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 8b1649222ec..84f71900e77 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -41,9 +41,6 @@ public final class io/getstream/chat/android/ui/common/feature/channel/attachmen public fun toString ()Ljava/lang/String; } -public abstract interface class io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction { -} - public final class io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$LoadMore : io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction { public static final field $stable I public static final field INSTANCE Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$LoadMore; @@ -449,18 +446,6 @@ public final class io/getstream/chat/android/ui/common/feature/channel/info/Chan public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction$UserInfoClick : io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction { - public static final field $stable I - public fun (Lio/getstream/chat/android/models/User;)V - public final fun component1 ()Lio/getstream/chat/android/models/User; - public final fun copy (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction$UserInfoClick; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction$UserInfoClick;Lio/getstream/chat/android/models/User;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction$UserInfoClick; - public fun equals (Ljava/lang/Object;)Z - public final fun getUser ()Lio/getstream/chat/android/models/User; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public abstract interface class io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent { } @@ -1639,32 +1624,6 @@ public final class io/getstream/chat/android/ui/common/state/channel/attachments public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState { - public static final field $stable I - public fun ()V - public fun (ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;)V - public synthetic fun (ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Z - public final fun component2 ()Z - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/util/List; - public final fun component5 ()Ljava/util/Set; - public final fun component6 ()Ljava/util/Set; - public final fun copy (ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;)Lio/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState;ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState; - public fun equals (Ljava/lang/Object;)Z - public final fun getLoadedMemberIds ()Ljava/util/Set; - public final fun getQuery ()Ljava/lang/String; - public final fun getSearchResult ()Ljava/util/List; - public final fun getSelectedUserIds ()Ljava/util/Set; - public fun hashCode ()I - public final fun isAlreadyMember (Lio/getstream/chat/android/models/User;)Z - public final fun isLoading ()Z - public final fun isLoadingMore ()Z - public final fun isSelected (Lio/getstream/chat/android/models/User;)Z - public fun toString ()Ljava/lang/String; -} - public final class io/getstream/chat/android/ui/common/state/channel/info/BanMember : io/getstream/chat/android/ui/common/state/channel/info/MemberAction { public static final field $stable I public fun (Lio/getstream/chat/android/models/Member;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V @@ -1766,6 +1725,18 @@ public final class io/getstream/chat/android/ui/common/state/channel/info/Channe public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$EditChannel : io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$EditChannel; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$EditChannel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$EditChannel; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$FilesAttachments : io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option { public static final field $stable I public static final field INSTANCE Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$FilesAttachments; @@ -1834,32 +1805,6 @@ public final class io/getstream/chat/android/ui/common/state/channel/info/Channe public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$RenameChannel : io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option { - public static final field $stable I - public fun (Ljava/lang/String;Z)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Z - public final fun copy (Ljava/lang/String;Z)Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$RenameChannel; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$RenameChannel;Ljava/lang/String;ZILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$RenameChannel; - public fun equals (Ljava/lang/Object;)Z - public final fun getName ()Ljava/lang/String; - public fun hashCode ()I - public final fun isReadOnly ()Z - public fun toString ()Ljava/lang/String; -} - -public final class io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$UserInfo : io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option { - public static final field $stable I - public fun (Lio/getstream/chat/android/models/User;)V - public final fun component1 ()Lio/getstream/chat/android/models/User; - public final fun copy (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$UserInfo; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$UserInfo;Lio/getstream/chat/android/models/User;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Content$Option$UserInfo; - public fun equals (Ljava/lang/Object;)Z - public final fun getUser ()Lio/getstream/chat/android/models/User; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Loading : io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState { public static final field $stable I public static final field INSTANCE Lio/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState$Loading; diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction.kt index 1e6ad6f9a5e..0023213ace0 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction.kt @@ -16,11 +16,13 @@ package io.getstream.chat.android.ui.common.feature.channel.info +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.User /** * Represents actions that can be performed from the "Add Members" view. */ +@InternalStreamChatApi public sealed interface AddMembersViewAction { /** diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt index 56dd2157847..c02ae47cdf6 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt @@ -66,6 +66,7 @@ public class AddMembersViewController( private val channelMembers = channelState.flatMapLatest { it.members } private var searchJob: Job? = null + private var loadMoreJob: Job? = null private val _state = MutableStateFlow(AddMembersViewState()) @@ -120,6 +121,7 @@ public class AddMembersViewController( private fun searchUsers(query: String) { searchJob?.cancel() + loadMoreJob?.cancel() searchJob = scope.launch { _state.update { it.copy(isLoading = true) } chatClient.queryUsers(query.trim().toSearchRequest(offset = 0)) @@ -137,7 +139,7 @@ public class AddMembersViewController( private fun loadMore() { if (_state.value.isLoading || _state.value.isLoadingMore) return - scope.launch { + loadMoreJob = scope.launch { val currentResult = _state.value.searchResult _state.update { it.copy(isLoadingMore = true) } chatClient.queryUsers(_state.value.query.trim().toSearchRequest(offset = currentResult.size)) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction.kt index 4831dc2a4b9..5cf9465e7bf 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewAction.kt @@ -18,7 +18,6 @@ package io.getstream.chat.android.ui.common.feature.channel.info import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.User /** * Represents actions that can be performed from the channel information view. @@ -41,13 +40,6 @@ public sealed interface ChannelInfoViewAction { */ public data class MemberClick(val member: Member) : ChannelInfoViewAction - /** - * Represents the user info click action. - * - * @param user The clicked user. - */ - public data class UserInfoClick(val user: User) : ChannelInfoViewAction - /** * Represents the rename channel click action. * diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt index e724c7e5342..51e5d3c1a1b 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt @@ -31,9 +31,7 @@ import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.MemberData import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Mute -import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewEvent.Navigation -import io.getstream.chat.android.ui.common.helper.CopyToClipboardHandler import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState import io.getstream.chat.android.ui.common.utils.ExpandableList import io.getstream.log.taggedLogger @@ -64,7 +62,6 @@ import kotlinx.coroutines.launch * * @param cid The unique identifier of the channel. * @param scope The [CoroutineScope] used for launching coroutines. - * @param copyToClipboardHandler The [CopyToClipboardHandler] used for copying text to the clipboard. * @param optionFilter A filter function for channel options, allowing customization of which options are displayed. * Defaults to a function that returns true for all options. * @param chatClient The [ChatClient] instance used for interacting with the chat API. @@ -76,7 +73,6 @@ import kotlinx.coroutines.launch public class ChannelInfoViewController( private val cid: String, private val scope: CoroutineScope, - private val copyToClipboardHandler: CopyToClipboardHandler, private val optionFilter: (option: ChannelInfoViewState.Content.Option) -> Boolean = { true }, private val chatClient: ChatClient = ChatClient.instance(), channelState: Flow = chatClient @@ -191,7 +187,6 @@ public class ChannelInfoViewController( is ChannelInfoViewAction.ExpandMembersClick -> expandMembers() is ChannelInfoViewAction.CollapseMembersClick -> collapseMembers() is ChannelInfoViewAction.MemberClick -> memberClick(action) - is ChannelInfoViewAction.UserInfoClick -> userInfoClick(action.user) is ChannelInfoViewAction.RenameChannelClick -> renameChannel(action.name) is ChannelInfoViewAction.PinnedMessagesClick -> _events.tryEmit(ChannelInfoViewEvent.NavigateToPinnedMessages) @@ -305,12 +300,6 @@ public class ChannelInfoViewController( ) } - private fun userInfoClick(user: User) { - logger.d { "[userInfoClick] user: $user" } - - copyToClipboardHandler.copy(text = "@${user.name}") - } - private fun renameChannel(name: String) { logger.d { "[renameChannel] name: $name" } @@ -572,18 +561,7 @@ private fun buildChannelOptionList( ) { add(ChannelInfoViewState.Content.Option.AddMember) } - if (singleMember != null) { - add(ChannelInfoViewState.Content.Option.UserInfo(user = singleMember.user)) - } else { - add( - ChannelInfoViewState.Content.Option.RenameChannel( - name = channelData.name, - isReadOnly = !channelData.ownCapabilities.contains(ChannelCapabilities.UPDATE_CHANNEL), - ), - ) - } if (isDmChannel) { - // DM channel: user-level mute instead of channel mute, no hide add(ChannelInfoViewState.Content.Option.PinnedMessages) add(ChannelInfoViewState.Content.Option.MediaAttachments) add(ChannelInfoViewState.Content.Option.FilesAttachments) @@ -595,7 +573,9 @@ private fun buildChannelOptionList( add(ChannelInfoViewState.Content.Option.DeleteChannel) } } else { - // Group channel: channel-level mute, leave + if (channelData.ownCapabilities.contains(ChannelCapabilities.UPDATE_CHANNEL)) { + add(ChannelInfoViewState.Content.Option.EditChannel(name = channelData.name)) + } if (channelData.ownCapabilities.contains(ChannelCapabilities.MUTE_CHANNEL)) { add(ChannelInfoViewState.Content.Option.MuteChannel(isMuted)) } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState.kt index 93a5496e937..a70a1d3f91f 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.ui.common.state.channel.info +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.User /** @@ -28,6 +29,7 @@ import io.getstream.chat.android.models.User * @param selectedUserIds The set of IDs of users selected to be added as members. * @param loadedMemberIds The set of IDs of users who are already members of the channel. */ +@InternalStreamChatApi public data class AddMembersViewState( val isLoading: Boolean = true, val isLoadingMore: Boolean = false, diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState.kt index 0155a321c7f..b2c732584b4 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoViewState.kt @@ -57,19 +57,11 @@ public sealed interface ChannelInfoViewState { public data object AddMember : Option /** - * Indicates an option with user information. - * - * @param user The user whose information is displayed. - */ - public data class UserInfo(val user: User) : Option - - /** - * Indicates an option to rename the channel. + * Indicates an option to edit the channel (name, image, etc.). * * @param name The current name of the channel. - * @param isReadOnly Indicates if the channel is read-only. */ - public data class RenameChannel(val name: String, val isReadOnly: Boolean) : Option + public data class EditChannel(val name: String) : Option /** * Indicates an option to mute the channel. diff --git a/stream-chat-android-ui-common/src/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index c1f6c02f9d9..ea93bb224ac 100644 --- a/stream-chat-android-ui-common/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml @@ -90,6 +90,7 @@ Contact Info Group Info + Edit Add View all @@ -100,10 +101,21 @@ Owner Moderator %d more - Copy user handle NAME Add a group name + + + Edit + Save changes + Add a group name + Upload + Edit Group Picture + Choose Image + Take Photo + Reset Picture + Failed to save changes + Failed to rename group Failed to switch the conversation mute state Failed to switch the group mute state diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt index df366f41dd2..d1b7f4ea869 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -247,6 +247,33 @@ internal class AddMembersViewControllerTest { } } + @Test + fun `QueryChanged after LoadMore cancels loadMore job`() = runTest { + val initialUsers = listOf(randomUser(), randomUser()) + val moreUsers = listOf(randomUser()) + val newSearchUsers = listOf(randomUser()) + val sut = Fixture() + .givenQueryUsers(users = initialUsers) // initial search + .givenQueryUsers(users = moreUsers) // loadMore + .givenQueryUsers(users = newSearchUsers) // new search after query change + .get(backgroundScope) + + sut.state.test { + skipItems(2) // initial state + initial search result + + sut.onViewAction(AddMembersViewAction.LoadMore) + skipItems(2) // isLoadingMore + loadMore result + + sut.onViewAction(AddMembersViewAction.QueryChanged("new")) + skipItems(1) // query state update + + assertTrue(awaitItem().isLoading) + val state = awaitItem() + assertFalse(state.isLoading) + assertEquals(newSearchUsers, state.searchResult) + } + } + @Test fun `LoadMore appends results to searchResult`() = runTest { val initialUsers = listOf(randomUser(), randomUser()) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt index 44489c183ea..1eee01e703b 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt @@ -41,7 +41,6 @@ import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewEvent.Navigation -import io.getstream.chat.android.ui.common.helper.CopyToClipboardHandler import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState import io.getstream.chat.android.ui.common.utils.ExpandableList import io.getstream.result.Error @@ -96,7 +95,6 @@ internal class ChannelInfoViewControllerTest { minimumVisibleItems = 5, ), options = listOf( - ChannelInfoViewState.Content.Option.UserInfo(user = currentMember.user), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -137,7 +135,6 @@ internal class ChannelInfoViewControllerTest { minimumVisibleItems = 5, ), options = listOf( - ChannelInfoViewState.Content.Option.UserInfo(user = otherMember.user), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -177,7 +174,6 @@ internal class ChannelInfoViewControllerTest { minimumVisibleItems = 5, ), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -207,7 +203,6 @@ internal class ChannelInfoViewControllerTest { minimumVisibleItems = 5, ), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -224,7 +219,6 @@ internal class ChannelInfoViewControllerTest { fun `expand and collapse group channel content`() = runTest { val channel = randomChannel(members = randomMembers(10), ownCapabilities = emptySet()) val options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -310,7 +304,6 @@ internal class ChannelInfoViewControllerTest { minimumVisibleItems = 5, ), options = listOf( - ChannelInfoViewState.Content.Option.UserInfo(user = otherMember.user), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -342,7 +335,6 @@ internal class ChannelInfoViewControllerTest { minimumVisibleItems = 5, ), options = listOf( - ChannelInfoViewState.Content.Option.UserInfo(user = otherMember.user), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -378,7 +370,6 @@ internal class ChannelInfoViewControllerTest { minimumVisibleItems = 5, ), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -409,10 +400,7 @@ internal class ChannelInfoViewControllerTest { ), options = listOf( ChannelInfoViewState.Content.Option.AddMember, - ChannelInfoViewState.Content.Option.RenameChannel( - name = updatedChannel.name, - isReadOnly = false, - ), + ChannelInfoViewState.Content.Option.EditChannel(name = updatedChannel.name), ChannelInfoViewState.Content.Option.MuteChannel(isMuted = false), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, @@ -426,17 +414,6 @@ internal class ChannelInfoViewControllerTest { } } - @Test - fun `user info click`() = runTest { - val user = randomUser() - val fixture = Fixture() - val sut = fixture.get(backgroundScope) - - sut.onViewAction(ChannelInfoViewAction.UserInfoClick(user)) - - fixture.verifyCopiedUserHandleToClipboard(text = "@${user.name}") - } - @Test fun `rename channel`() = runTest { val channel = randomChannel(ownCapabilities = setOf(ChannelCapabilities.UPDATE_CHANNEL)) @@ -452,7 +429,7 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = false), + ChannelInfoViewState.Content.Option.EditChannel(name = channel.name), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -472,7 +449,7 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = newName, isReadOnly = false), + ChannelInfoViewState.Content.Option.EditChannel(name = newName), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -575,7 +552,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.MuteChannel(isMuted = false), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, @@ -595,7 +571,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.MuteChannel(isMuted = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, @@ -622,7 +597,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.MuteChannel(isMuted = false), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, @@ -657,7 +631,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.MuteChannel(isMuted = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, @@ -677,7 +650,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.MuteChannel(isMuted = false), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, @@ -704,7 +676,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.MuteChannel(isMuted = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, @@ -755,7 +726,6 @@ internal class ChannelInfoViewControllerTest { ChannelInfoViewState.Content( members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = "", isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -791,7 +761,6 @@ internal class ChannelInfoViewControllerTest { ChannelInfoViewState.Content( members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = "", isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -827,7 +796,6 @@ internal class ChannelInfoViewControllerTest { ChannelInfoViewState.Content( members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = "", isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -855,7 +823,6 @@ internal class ChannelInfoViewControllerTest { ChannelInfoViewState.Content( members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = "", isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -930,7 +897,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -974,7 +940,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -1032,7 +997,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -1070,7 +1034,6 @@ internal class ChannelInfoViewControllerTest { owner = channel.createdBy, members = emptyMembers(), options = listOf( - ChannelInfoViewState.Content.Option.RenameChannel(name = channel.name, isReadOnly = true), ChannelInfoViewState.Content.Option.PinnedMessages, ChannelInfoViewState.Content.Option.MediaAttachments, ChannelInfoViewState.Content.Option.FilesAttachments, @@ -1401,7 +1364,6 @@ private class Fixture { } private val channelClient: ChannelClient = mock() private val chatClient: ChatClient = mock() - private val copyToClipboardHandler: CopyToClipboardHandler = mock() private var optionFilter: (ChannelInfoViewState.Content.Option) -> Boolean = { true } fun given( @@ -1527,10 +1489,6 @@ private class Fixture { verifyNoMoreInteractions(channelClient) } - fun verifyCopiedUserHandleToClipboard(text: String) = apply { - verify(copyToClipboardHandler).copy(text = text) - } - fun verifyMemberBanned(member: Member, timeout: Int?) = apply { verify(channelClient).banUser( targetId = member.getUserId(), @@ -1568,7 +1526,6 @@ private class Fixture { channelState = MutableStateFlow(channelState), channelClient = channelClient, globalState = flowOf(globalState), - copyToClipboardHandler = copyToClipboardHandler, ) } diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoExtensions.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoExtensions.kt index 3309f1d7584..006635229ad 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoExtensions.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoExtensions.kt @@ -36,10 +36,7 @@ internal fun List.toChannelInfoItems( // Not rendered as an option item is ChannelInfoViewState.Content.Option.AddMember -> Unit - // Not applicable in this UI - is ChannelInfoViewState.Content.Option.UserInfo -> Unit - - is ChannelInfoViewState.Content.Option.RenameChannel -> + is ChannelInfoViewState.Content.Option.EditChannel -> add(ChatInfoItem.ChannelName(option.name)) is ChannelInfoViewState.Content.Option.MuteChannel -> add( diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoFragment.kt index 0ce4a14e79e..a080350c33a 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/ChatInfoFragment.kt @@ -40,7 +40,7 @@ class ChatInfoFragment : Fragment() { private val args: ChatInfoFragmentArgs by navArgs() private val viewModel: ChannelInfoViewModel by viewModels { - ChannelInfoViewModelFactory(context = requireContext(), cid = args.cid) + ChannelInfoViewModelFactory(cid = args.cid) } private val adapter: ChatInfoAdapter = ChatInfoAdapter() diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/group/GroupChatInfoFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/group/GroupChatInfoFragment.kt index 813329d94c5..fc3b783e11e 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/group/GroupChatInfoFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/group/GroupChatInfoFragment.kt @@ -49,7 +49,7 @@ class GroupChatInfoFragment : Fragment() { private val args: GroupChatInfoFragmentArgs by navArgs() private val viewModel: ChannelInfoViewModel by viewModels { - ChannelInfoViewModelFactory(context = requireContext(), cid = args.cid) + ChannelInfoViewModelFactory(cid = args.cid) } private val headerViewModel: MessageListHeaderViewModel by viewModels { MessageListViewModelFactory(requireContext(), args.cid) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index d747ec1cd84..5cf1cc486a6 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -4226,8 +4226,8 @@ public final class io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoMem } public final class io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModel : androidx/lifecycle/ViewModel { - public fun (Ljava/lang/String;Lio/getstream/chat/android/ui/common/helper/CopyToClipboardHandler;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/ui/common/helper/CopyToClipboardHandler;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEvents ()Landroidx/lifecycle/LiveData; public final fun getState ()Landroidx/lifecycle/LiveData; public final fun onMemberViewEvent (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent;)V @@ -4235,8 +4235,8 @@ public final class io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoVie } public final class io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { - public fun (Landroid/content/Context;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModel.kt index a2d258d5274..10b69e00339 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModel.kt @@ -24,7 +24,6 @@ import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoMembe import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewAction import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewController import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewEvent -import io.getstream.chat.android.ui.common.helper.CopyToClipboardHandler import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState import io.getstream.chat.android.ui.utils.asSingleLiveEvent @@ -32,20 +31,17 @@ import io.getstream.chat.android.ui.utils.asSingleLiveEvent * ViewModel for managing channel information and its actions. * * @param cid The full channel identifier (e.g., "messaging:123"). - * @param copyToClipboardHandler The handler for copying text to the clipboard. * @param optionFilter A filter function for channel options, allowing customization of which options are displayed. * Defaults to a function that returns true for all options. * @param controllerProvider The provider for [ChannelInfoViewController]. */ public class ChannelInfoViewModel( private val cid: String, - private val copyToClipboardHandler: CopyToClipboardHandler, private val optionFilter: (option: ChannelInfoViewState.Content.Option) -> Boolean = { true }, controllerProvider: ViewModel.() -> ChannelInfoViewController = { ChannelInfoViewController( cid = cid, scope = viewModelScope, - copyToClipboardHandler = copyToClipboardHandler, optionFilter = optionFilter, ) }, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModelFactory.kt index dae6860a772..243c93ec968 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModelFactory.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channel/ChannelInfoViewModelFactory.kt @@ -16,22 +16,18 @@ package io.getstream.chat.android.ui.viewmodel.channel -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import io.getstream.chat.android.ui.common.helper.CopyToClipboardHandler import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState /** * Factory for creating instances of [ChannelInfoViewModel]. * - * @param context The application context. * @param cid The full channel identifier (Ex. "messaging:123"). * @param optionFilter A filter function for channel options, allowing customization of which options are displayed. * Defaults to a function that returns true for all options. */ public class ChannelInfoViewModelFactory( - private val context: Context, private val cid: String, private val optionFilter: (option: ChannelInfoViewState.Content.Option) -> Boolean = { true }, ) : ViewModelProvider.Factory { @@ -43,7 +39,6 @@ public class ChannelInfoViewModelFactory( @Suppress("UNCHECKED_CAST") return ChannelInfoViewModel( cid = cid, - copyToClipboardHandler = CopyToClipboardHandler(context = context.applicationContext), optionFilter = optionFilter, ) as T }