diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt index b6efcaf7e34..16a145a30f6 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt @@ -86,12 +86,11 @@ import io.getstream.chat.android.compose.ui.components.SearchInput import io.getstream.chat.android.compose.ui.components.channels.buildDefaultChannelActions import io.getstream.chat.android.compose.ui.mentions.MentionList import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.compose.ui.threads.ThreadList +import io.getstream.chat.android.compose.ui.threads.ThreadsScreen import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory import io.getstream.chat.android.compose.viewmodel.mentions.MentionListViewModel import io.getstream.chat.android.compose.viewmodel.mentions.MentionListViewModelFactory -import io.getstream.chat.android.compose.viewmodel.threads.ThreadListViewModel import io.getstream.chat.android.compose.viewmodel.threads.ThreadsViewModelFactory import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters @@ -126,10 +125,7 @@ class ChannelsActivity : ComponentActivity() { private val channelsViewModel: ChannelListViewModel by viewModels { channelsViewModelFactory } private val mentionListViewModel: MentionListViewModel by viewModels { MentionListViewModelFactory() } - private val threadsViewModel: ThreadListViewModel by viewModels { - val query = QueryThreadsRequest() - ThreadsViewModelFactory(query) - } + private val threadsViewModelFactory = ThreadsViewModelFactory(query = QueryThreadsRequest()) @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { @@ -212,7 +208,13 @@ class ChannelsActivity : ComponentActivity() { ) AppBottomBarOption.MENTIONS -> MentionsContent() - AppBottomBarOption.THREADS -> ThreadsContent() + AppBottomBarOption.THREADS -> ThreadsContent( + onHeaderAvatarClick = { + coroutineScope.launch { + drawerState.open() + } + }, + ) } } } @@ -251,10 +253,10 @@ class ChannelsActivity : ComponentActivity() { } @Composable - private fun ThreadsContent() { - ThreadList( - viewModel = threadsViewModel, - modifier = Modifier.fillMaxSize(), + private fun ThreadsContent(onHeaderAvatarClick: () -> Unit) { + ThreadsScreen( + viewModelFactory = threadsViewModelFactory, + onHeaderAvatarClick = onHeaderAvatarClick, onThreadClick = ::openThread, ) } 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 f799d845cfe..3bd25852922 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -3474,6 +3474,7 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public fun SwipeToReplyContent (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/ui/theme/SwipeToReplyContentParams;Landroidx/compose/runtime/Composer;I)V public fun ThreadListBanner (Lio/getstream/chat/android/compose/ui/theme/ThreadListBannerParams;Landroidx/compose/runtime/Composer;I)V public fun ThreadListEmptyContent (Lio/getstream/chat/android/compose/ui/theme/ThreadListEmptyContentParams;Landroidx/compose/runtime/Composer;I)V + public fun ThreadListHeader (Lio/getstream/chat/android/compose/ui/theme/ThreadListHeaderParams;Landroidx/compose/runtime/Composer;I)V public fun ThreadListItem (Lio/getstream/chat/android/compose/ui/theme/ThreadListItemParams;Landroidx/compose/runtime/Composer;I)V public fun ThreadListLoadingContent (Lio/getstream/chat/android/compose/ui/theme/ThreadListLoadingContentParams;Landroidx/compose/runtime/Composer;I)V public fun ThreadListLoadingMoreContent (Lio/getstream/chat/android/compose/ui/theme/ThreadListLoadingMoreContentParams;Landroidx/compose/runtime/Composer;I)V @@ -3662,6 +3663,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun SwipeToReplyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/ui/theme/SwipeToReplyContentParams;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListBanner (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ThreadListBannerParams;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListEmptyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ThreadListEmptyContentParams;Landroidx/compose/runtime/Composer;I)V + public static fun ThreadListHeader (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ThreadListHeaderParams;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListItem (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ThreadListItemParams;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListLoadingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ThreadListLoadingContentParams;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListLoadingMoreContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ThreadListLoadingMoreContentParams;Landroidx/compose/runtime/Composer;I)V @@ -6097,6 +6099,27 @@ public final class io/getstream/chat/android/compose/ui/theme/ThreadListEmptyCon public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/compose/ui/theme/ThreadListHeaderParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/models/ConnectionState;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lio/getstream/chat/android/models/ConnectionState;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/models/ConnectionState; + public final fun component2 ()Landroidx/compose/ui/Modifier; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lio/getstream/chat/android/models/User; + public final fun component5 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Lio/getstream/chat/android/models/ConnectionState;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;)Lio/getstream/chat/android/compose/ui/theme/ThreadListHeaderParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/ThreadListHeaderParams;Lio/getstream/chat/android/models/ConnectionState;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/ThreadListHeaderParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getConnectionState ()Lio/getstream/chat/android/models/ConnectionState; + public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; + public final fun getModifier ()Landroidx/compose/ui/Modifier; + public final fun getOnAvatarClick ()Lkotlin/jvm/functions/Function1; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/ui/theme/ThreadListItemParams { public static final field $stable I public fun (Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;)V @@ -6185,6 +6208,14 @@ public final class io/getstream/chat/android/compose/ui/threads/ComposableSingle public final fun getLambda$881034423$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } +public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListHeaderKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListHeaderKt; + public fun ()V + public final fun getLambda$-509567249$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-549714838$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1494410151$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; +} + public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt; public fun ()V @@ -6241,11 +6272,19 @@ public final class io/getstream/chat/android/compose/ui/threads/ThreadListBanner public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/compose/ui/threads/ThreadListHeaderKt { + public static final fun ThreadListHeader-ws93vos (Landroidx/compose/ui/Modifier;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/ConnectionState;JLandroidx/compose/ui/graphics/Shape;FLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + public final class io/getstream/chat/android/compose/ui/threads/ThreadListKt { public static final fun ThreadList (Lio/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V public static final fun ThreadList (Lio/getstream/chat/android/ui/common/state/threads/ThreadListState;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V } +public final class io/getstream/chat/android/compose/ui/threads/ThreadsScreenKt { + public static final fun ThreadsScreen (Lio/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + public final class io/getstream/chat/android/compose/ui/util/ChannelUtilsKt { public static final fun getLastMessage (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/models/Message; public static final fun getMembersStatusText (Lio/getstream/chat/android/models/Channel;Landroid/content/Context;Lio/getstream/chat/android/models/User;)Ljava/lang/String; @@ -6814,7 +6853,9 @@ public final class io/getstream/chat/android/compose/viewmodel/pinned/PinnedMess public final class io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel : androidx/lifecycle/ViewModel { public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/feature/threads/ThreadListController;)V + public final fun getConnectionState ()Lkotlinx/coroutines/flow/StateFlow; public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getUser ()Lkotlinx/coroutines/flow/StateFlow; public final fun load ()V public final fun loadNextPage ()V } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/header/ChannelListHeader.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/header/ChannelListHeader.kt index bb135704113..e93193f67bc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/header/ChannelListHeader.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/header/ChannelListHeader.kt @@ -16,25 +16,10 @@ package io.getstream.chat.android.compose.ui.channels.header -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -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.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape @@ -45,16 +30,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator -import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.components.ListHeader import io.getstream.chat.android.compose.ui.components.button.StreamButton import io.getstream.chat.android.compose.ui.theme.ChannelListHeaderCenterContentParams import io.getstream.chat.android.compose.ui.theme.ChannelListHeaderLeadingContentParams import io.getstream.chat.android.compose.ui.theme.ChannelListHeaderTrailingContentParams 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.theme.UserAvatarParams -import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.User import io.getstream.chat.android.previewdata.PreviewUserData @@ -121,107 +102,15 @@ public fun ChannelListHeader( } }, ) { - Surface( - modifier = modifier - .fillMaxWidth(), - shadowElevation = elevation, + ListHeader( + modifier = modifier, color = color, shape = shape, - ) { - Column { - Row( - Modifier - .fillMaxWidth() - .padding(StreamTokens.spacingSm), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), - ) { - leadingContent() - - centerContent() - - trailingContent() - } - HorizontalDivider( - thickness = 1.dp, - color = ChatTheme.colors.borderCoreDefault, - ) - } - } -} - -/** - * Represents the default leading content of a channel list header, which is the currently logged-in user avatar. - * - * We show the avatar if the user is available, otherwise we add a spacer to make sure the alignment is correct. - */ -@Composable -internal fun DefaultChannelListHeaderLeadingContent( - currentUser: User?, - onAvatarClick: (User?) -> Unit, -) { - if (currentUser != null) { - Box( - modifier = Modifier - .size(AvatarSize.ExtraLarge) - .clip(CircleShape) - .clickable { onAvatarClick(currentUser) }, - contentAlignment = Alignment.Center, - ) { - ChatTheme.componentFactory.UserAvatar( - params = UserAvatarParams( - modifier = Modifier - .size(AvatarSize.Large) - .testTag("Stream_UserAvatar"), - user = currentUser, - ), - ) - } - } else { - Spacer(modifier = Modifier.size(AvatarSize.ExtraLarge)) - } -} - -/** - * Represents the channel header's center slot. It either shows a [Text] if [connectionState] is - * [ConnectionState.CONNECTED], or a [NetworkLoadingIndicator] if there is no connections. - * - * @param connectionState The state of WebSocket connection. - * @param title The title to show. - */ -@Composable -internal fun RowScope.DefaultChannelListHeaderCenterContent( - connectionState: ConnectionState, - title: String, -) { - when (connectionState) { - is ConnectionState.Connected -> { - Text( - modifier = Modifier - .weight(1f) - .wrapContentWidth() - .padding(horizontal = StreamTokens.spacingMd), - text = title, - style = ChatTheme.typography.headingSmall, - maxLines = 1, - color = ChatTheme.colors.textPrimary, - ) - } - - is ConnectionState.Connecting -> NetworkLoadingIndicator(modifier = Modifier.weight(1f)) - is ConnectionState.Offline -> { - Text( - modifier = Modifier - .weight(1f) - .wrapContentWidth() - .padding(horizontal = StreamTokens.spacingMd), - text = stringResource(R.string.stream_compose_disconnected), - style = ChatTheme.typography.headingSmall, - maxLines = 1, - color = ChatTheme.colors.textPrimary, - ) - } - } + elevation = elevation, + leadingContent = leadingContent, + centerContent = centerContent, + trailingContent = trailingContent, + ) } /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ListHeader.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ListHeader.kt new file mode 100644 index 00000000000..b8f3f91013e --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ListHeader.kt @@ -0,0 +1,177 @@ +/* + * 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.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +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.theme.UserAvatarParams +import io.getstream.chat.android.compose.ui.util.clickable +import io.getstream.chat.android.models.ConnectionState +import io.getstream.chat.android.models.User + +/** + * Common header layout. + * + * @param modifier Modifier for styling. + * @param color The background color of the header. + * @param shape The shape of the header. + * @param elevation The elevation of the header. + * @param leadingContent Composable for the leading slot (e.g. user avatar). + * @param centerContent Composable for the center slot (e.g. title or loading indicator). + * @param trailingContent Composable for the trailing slot (e.g. action button or spacer). + */ +@Composable +internal fun ListHeader( + modifier: Modifier = Modifier, + color: Color = ChatTheme.colors.backgroundCoreElevation1, + shape: Shape = RectangleShape, + elevation: Dp = 0.dp, + leadingContent: @Composable RowScope.() -> Unit, + centerContent: @Composable RowScope.() -> Unit, + trailingContent: @Composable RowScope.() -> Unit, +) { + Surface( + modifier = modifier + .fillMaxWidth(), + shadowElevation = elevation, + color = color, + shape = shape, + ) { + Column { + Row( + Modifier + .fillMaxWidth() + .padding(StreamTokens.spacingSm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + ) { + leadingContent() + + centerContent() + + trailingContent() + } + HorizontalDivider( + thickness = 1.dp, + color = ChatTheme.colors.borderCoreDefault, + ) + } + } +} + +/** + * Default leading content for a [ListHeader]. + * Shows the user avatar if available, otherwise a spacer to preserve alignment. + * + * @param currentUser The currently logged in user. + * @param onAvatarClick Action invoked when the avatar is clicked. + */ +@Composable +internal fun DefaultListHeaderLeadingContent( + currentUser: User?, + onAvatarClick: (User?) -> Unit, +) { + if (currentUser != null) { + Box( + modifier = Modifier + .size(AvatarSize.ExtraLarge) + .clip(CircleShape) + .clickable { onAvatarClick(currentUser) }, + contentAlignment = Alignment.Center, + ) { + ChatTheme.componentFactory.UserAvatar( + params = UserAvatarParams( + modifier = Modifier + .size(AvatarSize.Large) + .testTag("Stream_UserAvatar"), + user = currentUser, + ), + ) + } + } else { + Spacer(modifier = Modifier.size(AvatarSize.ExtraLarge)) + } +} + +/** + * Default center content for a [ListHeader]. + * Shows a title when connected, a loading indicator when connecting, or "Disconnected" when offline. + * + * @param connectionState The state of WebSocket connection. + * @param title The title to show. + */ +@Composable +internal fun RowScope.DefaultListHeaderCenterContent( + connectionState: ConnectionState, + title: String, +) { + when (connectionState) { + is ConnectionState.Connected -> { + Text( + modifier = Modifier + .weight(1f) + .wrapContentWidth() + .padding(horizontal = StreamTokens.spacingMd), + text = title, + style = ChatTheme.typography.headingSmall, + maxLines = 1, + color = ChatTheme.colors.textPrimary, + ) + } + + is ConnectionState.Connecting -> NetworkLoadingIndicator(modifier = Modifier.weight(1f)) + is ConnectionState.Offline -> { + Text( + modifier = Modifier + .weight(1f) + .wrapContentWidth() + .padding(horizontal = StreamTokens.spacingMd), + text = stringResource(R.string.stream_compose_disconnected), + style = ChatTheme.typography.headingSmall, + maxLines = 1, + color = ChatTheme.colors.textPrimary, + ) + } + } +} 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 4ad01fbe3d2..b6294df7486 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 @@ -60,8 +60,6 @@ import io.getstream.chat.android.compose.state.messages.attachments.PollPickerMo import io.getstream.chat.android.compose.ui.attachments.content.UnsupportedAttachmentContent import io.getstream.chat.android.compose.ui.attachments.content.onFileAttachmentContentItemClick import io.getstream.chat.android.compose.ui.channel.info.ChannelInfoNavigationIcon -import io.getstream.chat.android.compose.ui.channels.header.DefaultChannelListHeaderCenterContent -import io.getstream.chat.android.compose.ui.channels.header.DefaultChannelListHeaderLeadingContent import io.getstream.chat.android.compose.ui.channels.header.DefaultChannelListHeaderTrailingContent import io.getstream.chat.android.compose.ui.channels.info.DefaultSelectedChannelMenuHeaderContent import io.getstream.chat.android.compose.ui.channels.info.SelectedChannelMenu @@ -80,6 +78,8 @@ import io.getstream.chat.android.compose.ui.channels.list.DefaultSearchResultIte import io.getstream.chat.android.compose.ui.channels.list.LocalSwipeRevealCoordinator import io.getstream.chat.android.compose.ui.channels.list.SearchResultItem import io.getstream.chat.android.compose.ui.channels.list.SwipeableChannelItem +import io.getstream.chat.android.compose.ui.components.DefaultListHeaderCenterContent +import io.getstream.chat.android.compose.ui.components.DefaultListHeaderLeadingContent import io.getstream.chat.android.compose.ui.components.DefaultSearchClearButton import io.getstream.chat.android.compose.ui.components.DefaultSearchLabel import io.getstream.chat.android.compose.ui.components.DefaultSearchLeadingIcon @@ -262,7 +262,7 @@ public interface ChatComponentFactory { */ @Composable public fun RowScope.ChannelListHeaderLeadingContent(params: ChannelListHeaderLeadingContentParams) { - DefaultChannelListHeaderLeadingContent( + DefaultListHeaderLeadingContent( currentUser = params.currentUser, onAvatarClick = params.onAvatarClick, ) @@ -278,7 +278,7 @@ public interface ChatComponentFactory { */ @Composable public fun RowScope.ChannelListHeaderCenterContent(params: ChannelListHeaderCenterContentParams) { - DefaultChannelListHeaderCenterContent( + DefaultListHeaderCenterContent( connectionState = params.connectionState, title = params.title, ) @@ -2053,6 +2053,23 @@ public interface ChatComponentFactory { ) } + /** + * The default header shown above the thread list. + * Displays the user avatar and a title, typically "Threads", with no trailing action button. + * + * @param params Parameters for this component. + */ + @Composable + public fun ThreadListHeader(params: ThreadListHeaderParams) { + io.getstream.chat.android.compose.ui.threads.ThreadListHeader( + modifier = params.modifier, + title = params.title, + currentUser = params.currentUser, + connectionState = params.connectionState, + onAvatarClick = params.onAvatarClick, + ) + } + /** * The default thread list banner. * Shows unread thread count, a loading indicator during refresh, or an error prompt. 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 ed9fe9033a6..13d4fd7af37 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 @@ -1609,6 +1609,23 @@ public data class MenuOptionItemParams( val horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, ) +/** + * Parameters for [ChatComponentFactory.ThreadListHeader]. + * + * @param connectionState The current connection state. + * @param modifier Modifier for styling. + * @param title The title to display in the header. + * @param currentUser The currently logged in user. + * @param onAvatarClick Action invoked when the avatar is clicked. + */ +public data class ThreadListHeaderParams( + val connectionState: ConnectionState, + val modifier: Modifier = Modifier, + val title: String = "", + val currentUser: User? = null, + val onAvatarClick: (User?) -> Unit = {}, +) + /** * Parameters for [ChatComponentFactory.ThreadListBanner]. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListHeader.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListHeader.kt new file mode 100644 index 00000000000..ad20f422ae4 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListHeader.kt @@ -0,0 +1,146 @@ +/* + * 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.threads + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.DefaultListHeaderCenterContent +import io.getstream.chat.android.compose.ui.components.DefaultListHeaderLeadingContent +import io.getstream.chat.android.compose.ui.components.ListHeader +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.models.ConnectionState +import io.getstream.chat.android.models.User +import io.getstream.chat.android.previewdata.PreviewUserData + +/** + * A header composable for the thread list screen. + * Uses the shared [ListHeader] layout with no trailing action button. + * + * @param modifier Modifier for styling. + * @param title The title to display, when the network is available. + * @param currentUser The currently logged in user, to load its image in the avatar. + * @param connectionState The state of WS connection used to switch between the title and the network loading view. + * @param color The color of the header. + * @param shape The shape of the header. + * @param elevation The elevation of the header. + * @param onAvatarClick Action handler when the user taps on an avatar. + */ +@Composable +public fun ThreadListHeader( + modifier: Modifier = Modifier, + title: String = stringResource(R.string.stream_compose_thread_list_header_title), + currentUser: User? = null, + connectionState: ConnectionState = ConnectionState.Connected, + color: Color = ChatTheme.colors.backgroundCoreElevation1, + shape: Shape = RectangleShape, + elevation: Dp = 0.dp, + onAvatarClick: (User?) -> Unit = {}, +) { + ListHeader( + modifier = modifier, + color = color, + shape = shape, + elevation = elevation, + leadingContent = { + DefaultListHeaderLeadingContent( + currentUser = currentUser, + onAvatarClick = onAvatarClick, + ) + }, + centerContent = { + DefaultListHeaderCenterContent( + connectionState = connectionState, + title = title, + ) + }, + trailingContent = { + Spacer(modifier = Modifier.size(AvatarSize.ExtraLarge)) + }, + ) +} + +@Composable +internal fun ThreadListHeaderConnectedNoUser() { + ThreadListHeader( + connectionState = ConnectionState.Connected, + ) +} + +@Composable +internal fun ThreadListHeaderConnectedWithUser() { + ThreadListHeader( + currentUser = PreviewUserData.user1, + connectionState = ConnectionState.Connected, + ) +} + +@Composable +internal fun ThreadListHeaderConnectingNoUser() { + ThreadListHeader( + connectionState = ConnectionState.Connecting, + ) +} + +@Composable +internal fun ThreadListHeaderConnectingWithUser() { + ThreadListHeader( + currentUser = PreviewUserData.user1, + connectionState = ConnectionState.Connecting, + ) +} + +@Composable +internal fun ThreadListHeaderOfflineNoUser() { + ThreadListHeader( + connectionState = ConnectionState.Offline, + ) +} + +@Composable +internal fun ThreadListHeaderOfflineWithUser() { + ThreadListHeader( + currentUser = PreviewUserData.user1, + connectionState = ConnectionState.Offline, + ) +} + +@Preview +@Composable +private fun ThreadListHeaderConnectedPreview() { + ChatTheme { + ThreadListHeaderConnectedWithUser() + } +} + +@Preview +@Composable +private fun ThreadListHeaderConnectingPreview() { + ChatTheme { + ThreadListHeaderConnectingWithUser() + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadsScreen.kt new file mode 100644 index 00000000000..0992eb5c278 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadsScreen.kt @@ -0,0 +1,73 @@ +/* + * 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.threads + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import io.getstream.chat.android.client.api.models.QueryThreadsRequest +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.ThreadListHeaderParams +import io.getstream.chat.android.compose.viewmodel.threads.ThreadListViewModel +import io.getstream.chat.android.compose.viewmodel.threads.ThreadsViewModelFactory +import io.getstream.chat.android.models.Thread + +/** + * Default root Threads screen component, that provides the necessary ViewModel. + * + * It can be used without most parameters for default behavior, that can be tweaked if necessary. + * + * @param viewModelFactory The factory used to build the [ThreadListViewModel]. + * @param title Header title. + * @param onHeaderAvatarClick Handle for when the user clicks on the header avatar. + * @param onThreadClick Handler for Thread item clicks. + */ +@Composable +public fun ThreadsScreen( + viewModelFactory: ThreadsViewModelFactory = ThreadsViewModelFactory(query = QueryThreadsRequest()), + title: String = stringResource(R.string.stream_compose_thread_list_header_title), + onHeaderAvatarClick: () -> Unit = {}, + onThreadClick: (Thread) -> Unit = {}, +) { + val listViewModel: ThreadListViewModel = viewModel(factory = viewModelFactory) + + val user by listViewModel.user.collectAsState() + val connectionState by listViewModel.connectionState.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + ChatTheme.componentFactory.ThreadListHeader( + params = ThreadListHeaderParams( + title = title, + currentUser = user, + connectionState = connectionState, + onAvatarClick = { onHeaderAvatarClick() }, + ), + ) + + ThreadList( + viewModel = listViewModel, + modifier = Modifier.fillMaxSize(), + onThreadClick = onThreadClick, + ) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt index 09516f7f006..7d2b513260b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt @@ -17,6 +17,8 @@ package io.getstream.chat.android.compose.viewmodel.threads import androidx.lifecycle.ViewModel +import io.getstream.chat.android.models.ConnectionState +import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.feature.threads.ThreadListController import io.getstream.chat.android.ui.common.state.threads.ThreadListState import kotlinx.coroutines.flow.StateFlow @@ -34,6 +36,16 @@ public class ThreadListViewModel(private val controller: ThreadListController) : */ public val state: StateFlow = controller.state + /** + * The state of our network connection - if we're online, connecting or offline. + */ + public val connectionState: StateFlow = controller.connectionState + + /** + * The state of the currently logged in user. + */ + public val user: StateFlow = controller.user + /** * Loads the initial data when requested. * Overrides all previously retrieved data. diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index 239a985cdab..2a3d8e98184 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -335,6 +335,7 @@ Already a member + Threads Reply to a message to start a thread " in " diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/threads/ThreadListHeaderTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/threads/ThreadListHeaderTest.kt new file mode 100644 index 00000000000..f613cbf4884 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/threads/ThreadListHeaderTest.kt @@ -0,0 +1,71 @@ +/* + * 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.threads + +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 ThreadListHeaderTest : PaparazziComposeTest { + + @get:Rule + override val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_2) + + @Test + fun `connected, no user`() { + snapshotWithDarkMode { + ThreadListHeaderConnectedNoUser() + } + } + + @Test + fun `connected, with user`() { + snapshotWithDarkMode { + ThreadListHeaderConnectedWithUser() + } + } + + @Test + fun `connecting, no user`() { + snapshotWithDarkMode { + ThreadListHeaderConnectingNoUser() + } + } + + @Test + fun `connecting, with user`() { + snapshotWithDarkMode { + ThreadListHeaderConnectingWithUser() + } + } + + @Test + fun `offline, no user`() { + snapshotWithDarkMode { + ThreadListHeaderOfflineNoUser() + } + } + + @Test + fun `offline, with user`() { + snapshotWithDarkMode { + ThreadListHeaderOfflineWithUser() + } + } +} diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connected,_no_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connected,_no_user.png new file mode 100644 index 00000000000..cec6075906c Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connected,_no_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connected,_with_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connected,_with_user.png new file mode 100644 index 00000000000..1829d876aea Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connected,_with_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connecting,_no_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connecting,_no_user.png new file mode 100644 index 00000000000..de310e99182 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connecting,_no_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connecting,_with_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connecting,_with_user.png new file mode 100644 index 00000000000..2b74044aad4 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_connecting,_with_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_offline,_no_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_offline,_no_user.png new file mode 100644 index 00000000000..df038e22410 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_offline,_no_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_offline,_with_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_offline,_with_user.png new file mode 100644 index 00000000000..b3729ca74af Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.threads_ThreadListHeaderTest_offline,_with_user.png differ diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt index be9fe0de96c..c352e4b17b0 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt @@ -22,6 +22,8 @@ import io.getstream.chat.android.client.api.state.QueryThreadsState import io.getstream.chat.android.client.api.state.queryThreadsAsState import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider +import io.getstream.chat.android.models.ConnectionState +import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.state.threads.ThreadListState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -63,6 +65,16 @@ public class ThreadListController( public val state: StateFlow get() = _state + /** + * The state of our network connection - if we're online, connecting or offline. + */ + public val connectionState: StateFlow = chatClient.clientState.connectionState + + /** + * The state of the currently logged in user. + */ + public val user: StateFlow = chatClient.clientState.user + private val queryThreadsState = queryThreadsAsState() init { diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt index 658bc7b6cb5..f485d03eb4b 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt @@ -20,6 +20,8 @@ import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.api.state.QueryThreadsState +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.QueryThreadsResult import io.getstream.chat.android.randomString import io.getstream.chat.android.randomThread @@ -205,7 +207,14 @@ internal class ThreadListControllerTest { private val queryThreadsStateFlow = MutableStateFlow(null) - private val mockChatClient: ChatClient = mock() + private val mockClientState: ClientState = mock { + on { connectionState } doReturn MutableStateFlow(ConnectionState.Connected) + on { user } doReturn MutableStateFlow(null) + } + + private val mockChatClient: ChatClient = mock { + on { clientState } doReturn mockClientState + } fun givenQueryThreadsState(state: QueryThreadsState) = apply { queryThreadsStateFlow.value = state