From 770a5a8c430ba72e3273a4c47f49e3988f5c9032 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 23 Sep 2024 16:46:06 +0900 Subject: [PATCH] add tab row to profile ui --- app/build.gradle.kts | 1 + .../flare/ui/screen/profile/ProfileScreen.kt | 432 +++++++++--------- app/src/main/res/values/strings.xml | 4 + gradle/libs.versions.toml | 1 + 4 files changed, 229 insertions(+), 209 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ddce318a7..dfa8e0580 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -144,6 +144,7 @@ dependencies { implementation(libs.materialKolor) implementation(libs.colorpicker.compose) implementation(libs.material.motion.compose) + implementation(libs.nestedScrollView) if (project.file("google-services.json").exists()) { implementation(platform(libs.firebase.bom)) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt index 269e3b48d..26362def6 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt @@ -1,20 +1,16 @@ package dev.dimension.flare.ui.screen.profile -import android.os.Build +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ContextualFlowRow -import androidx.compose.foundation.layout.ContextualFlowRowOverflow -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,8 +22,11 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll @@ -41,6 +40,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -57,7 +58,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color @@ -66,7 +66,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import androidx.window.core.layout.WindowWidthSizeClass @@ -77,8 +76,8 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.parameters.DeepLink import com.ramcosta.composedestinations.annotation.parameters.FULL_ROUTE_PLACEHOLDER import com.ramcosta.composedestinations.generated.destinations.EditAccountListRouteDestination -import com.ramcosta.composedestinations.generated.destinations.MediaRouteDestination import com.ramcosta.composedestinations.generated.destinations.ProfileMediaRouteDestination +import com.ramcosta.composedestinations.generated.destinations.StatusMediaRouteDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid @@ -92,9 +91,11 @@ import compose.icons.fontawesomeicons.solid.Robot import dev.dimension.flare.R import dev.dimension.flare.common.AppDeepLink import dev.dimension.flare.common.PagingState +import dev.dimension.flare.common.isLoading +import dev.dimension.flare.common.isSuccess +import dev.dimension.flare.common.onLoading import dev.dimension.flare.common.onSuccess import dev.dimension.flare.data.datasource.microblog.ProfileAction -import dev.dimension.flare.data.model.LocalAppearanceSettings import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.molecule.producePresenter @@ -117,6 +118,7 @@ import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiRelation import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.UiTimeline import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess @@ -130,6 +132,8 @@ import dev.dimension.flare.ui.theme.MediumAlpha import dev.dimension.flare.ui.theme.screenHorizontalPadding import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.launch +import moe.tlaster.nestedscrollview.VerticalNestedScrollView +import moe.tlaster.nestedscrollview.rememberNestedScrollViewState import kotlin.math.max @Composable @@ -172,10 +176,13 @@ internal fun ProfileWithUserNameAndHostDeeplinkRoute( ), ) }, - onMediaClick = { + onMediaClick = { statusKey, index, preview -> navigator.navigate( - MediaRouteDestination( - it, + StatusMediaRouteDestination( + statusKey = statusKey, + index = index, + preview = preview, + accountType = AccountType.Specific(accountKey), ), ) }, @@ -243,10 +250,13 @@ internal fun ProfileWithUserNameAndHostRoute( ), ) }, - onMediaClick = { + onMediaClick = { statusKey, index, preview -> navigator.navigate( - MediaRouteDestination( - it, + StatusMediaRouteDestination( + statusKey = statusKey, + index = index, + preview = preview, + accountType = accountType, ), ) }, @@ -380,10 +390,13 @@ internal fun ProfileDeeplinkRoute( ), ) }, - onMediaClick = { + onMediaClick = { statusKey, index, preview -> navigator.navigate( - MediaRouteDestination( - it, + StatusMediaRouteDestination( + statusKey = statusKey, + index = index, + preview = preview, + accountType = AccountType.Specific(accountKey), ), ) }, @@ -430,10 +443,13 @@ internal fun ProfileRoute( ), ) }, - onMediaClick = { + onMediaClick = { statusKey, index, preview -> navigator.navigate( - MediaRouteDestination( - it, + StatusMediaRouteDestination( + statusKey = statusKey, + index = index, + preview = preview, + accountType = accountType, ), ) }, @@ -457,13 +473,13 @@ private fun ProfileScreen( onBack: () -> Unit = {}, showBackButton: Boolean = true, onProfileMediaClick: () -> Unit = {}, - onMediaClick: (url: String) -> Unit = {}, - contentPadding: PaddingValues = PaddingValues(0.dp), + onMediaClick: (statusKey: MicroBlogKey, index: Int, preview: String?) -> Unit, ) { val state by producePresenter(key = "${accountType}_$userKey") { profilePresenter(userKey = userKey, accountType = accountType) } val listState = rememberLazyStaggeredGridState() + val nestedScrollState = rememberNestedScrollViewState() RegisterTabCallback(lazyListState = listState) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val windowInfo = currentWindowAdaptiveInfo() @@ -472,6 +488,7 @@ private fun ProfileScreen( currentWindowSize().toSize().toDpSize() } val bigScreen = windowInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED + val scope = rememberCoroutineScope() FlareScaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), contentWindowInsets = @@ -481,20 +498,14 @@ private fun ProfileScreen( topBar = { val titleAlpha by remember { derivedStateOf { - if (listState.firstVisibleItemIndex > 0 || - listState.layoutInfo.visibleItemsInfo.isEmpty() || + if (nestedScrollState.offset == nestedScrollState.maxOffset || bigScreen ) { 1f } else { max( 0f, - ( - listState.firstVisibleItemScrollOffset / - listState.layoutInfo.visibleItemsInfo[0] - .size.height - .toFloat() - ), + nestedScrollState.offset / nestedScrollState.maxOffset, ) } } @@ -513,24 +524,14 @@ private fun ProfileScreen( Modifier .fillMaxWidth() .windowInsetsTopHeight(WindowInsets.statusBars) - .background( - color = - MaterialTheme.colorScheme.surfaceColorAtElevation( - 3.dp, - ), - ), + .background(MaterialTheme.colorScheme.background), ) Spacer( modifier = Modifier .fillMaxWidth() .height(64.dp) - .background( - color = - MaterialTheme.colorScheme.surfaceColorAtElevation( - 3.dp, - ), - ), + .background(MaterialTheme.colorScheme.background), ) } } @@ -615,49 +616,31 @@ private fun ProfileScreen( expandMatrices = true, onAvatarClick = { state.state.userState.onSuccess { - onMediaClick(it.avatar) +// onMediaClick(it.avatar) } }, onBannerClick = { state.state.userState.onSuccess { - it.banner?.let { it1 -> onMediaClick(it1) } +// it.banner?.let { it1 -> onMediaClick(it1) } } }, ) } - Card { - ProfileMeidasPreview( - mediaState = state.state.mediaState, - maxLines = 2, - itemSize = 128.dp, - modifier = - Modifier.clickable { - onProfileMediaClick.invoke() - }, - ) - } } } RefreshContainer( modifier = Modifier.fillMaxSize(), onRefresh = state::refresh, isRefreshing = state.isRefreshing, - indicatorPadding = it + contentPadding, + indicatorPadding = it, content = { - LazyStatusVerticalStaggeredGrid( - state = listState, - contentPadding = - contentPadding + - if (bigScreen) { - it - } else { - PaddingValues(0.dp) - }, - ) { - if (!bigScreen) { - item( - span = StaggeredGridItemSpan.FullLine, - ) { + val pagerState = rememberPagerState { state.profileTabs.size } + + VerticalNestedScrollView( + state = nestedScrollState, + contentTopPadding = it.calculateTopPadding(), + header = { + Column { ProfileHeader( state.state.userState, state.state.relationState, @@ -669,41 +652,133 @@ private fun ProfileScreen( expandMatrices = false, onAvatarClick = { state.state.userState.onSuccess { - onMediaClick(it.avatar) +// onMediaClick(it.avatar) } }, onBannerClick = { state.state.userState.onSuccess { - it.banner?.let { it1 -> onMediaClick(it1) } +// it.banner?.let { it1 -> onMediaClick(it1) } } }, ) } - item { - ProfileMeidasPreview( - mediaState = state.state.mediaState, - maxLines = 1, - itemSize = 64.dp, - modifier = - Modifier.clickable { - onProfileMediaClick.invoke() - }, - ) - } - state.state.mediaState.onSuccess { - item { - HorizontalDivider() + }, + content = { + Column { + AnimatedVisibility(state.profileTabs.size > 1) { + SecondaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + modifier = Modifier.fillMaxWidth(), + edgePadding = screenHorizontalPadding, + divider = {}, + ) { + state.profileTabs.forEachIndexed { index, profileTab -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + ) { + Text( + profileTab.title, + modifier = + Modifier + .padding(8.dp), + ) + } + } + } + } + HorizontalDivider() + HorizontalPager( + state = pagerState, + ) { index -> + val type = state.profileTabs[index] + when (type) { + ProfileTab.Timeline -> + LazyStatusVerticalStaggeredGrid( + state = listState, + contentPadding = PaddingValues(vertical = 8.dp), + ) { + status(state.state.listState) + } + ProfileTab.Media -> { + ProfileMediaTab( + mediaState = state.state.mediaState, + onItemClicked = { statusKey, index, preview -> + onMediaClick(statusKey, index, preview) + }, + ) + } + } } } - } - status(state.state.listState) - } + }, + ) }, ) } } } +@Composable +private fun ProfileMediaTab( + mediaState: PagingState, + onItemClicked: (statusKey: MicroBlogKey, index: Int, preview: String?) -> Unit, + modifier: Modifier = Modifier, +) { + LazyVerticalStaggeredGrid( + modifier = modifier, + columns = StaggeredGridCells.Adaptive(120.dp), + verticalItemSpacing = 8.dp, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 8.dp, horizontal = screenHorizontalPadding), + ) { + mediaState + .onSuccess { + items(itemCount) { index -> + val item = get(index) + if (item != null) { + val media = item.media + MediaItem( + media = media, + showCountdown = false, + modifier = + Modifier + .clip(MaterialTheme.shapes.medium) + .clipToBounds() + .clickable { + val content = item.status.content + if (content is UiTimeline.ItemContent.Status) { + onItemClicked( + content.statusKey, + item.index, + when (media) { + is UiMedia.Image -> media.previewUrl + is UiMedia.Video -> media.thumbnailUrl + is UiMedia.Gif -> media.previewUrl + else -> null + }, + ) + } + }, + ) + } else { + Card { + Box(modifier = Modifier.size(120.dp).placeholder(true)) + } + } + } + }.onLoading { + items(10) { + Box(modifier = Modifier.size(120.dp).placeholder(true)) + } + } + } +} + @Composable private fun ProfileMenu( profileState: ProfileState, @@ -909,28 +984,39 @@ private fun ProfileHeaderSuccess( } is UiState.Success -> { - FilledTonalButton(onClick = { - onFollowClick.invoke(user.key, relationState.data) - }) { - Text( - text = - stringResource( - id = - when { - relationState.data.blocking -> - R.string.profile_header_button_blocked + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + FilledTonalButton(onClick = { + onFollowClick.invoke(user.key, relationState.data) + }) { + Text( + text = + stringResource( + id = + when { + relationState.data.blocking -> + R.string.profile_header_button_blocked - relationState.data.following -> - R.string.profile_header_button_following + relationState.data.following -> + R.string.profile_header_button_following - relationState.data.hasPendingFollowRequestFromYou -> - R.string.profile_header_button_requested + relationState.data.hasPendingFollowRequestFromYou -> + R.string.profile_header_button_requested - else -> - R.string.profile_header_button_follow - }, - ), - ) + else -> + R.string.profile_header_button_follow + }, + ), + ) + } + if (relationState.data.isFans) { + Text( + text = stringResource(R.string.profile_header_button_is_fans), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + ) + } } } } @@ -1080,7 +1166,7 @@ internal fun CommonProfileHeader( // ), // placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, // ) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) +// .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) .padding(bottom = 8.dp), ) { bannerUrl?.let { @@ -1233,7 +1319,7 @@ internal fun ProfileHeaderLoading( Box( modifier = modifier - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) +// .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) .padding(bottom = 8.dp), ) { Box( @@ -1298,103 +1384,6 @@ internal fun ProfileHeaderLoading( } } -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun ProfileMeidasPreview( - mediaState: PagingState, - maxLines: Int, - itemSize: Dp, - modifier: Modifier = Modifier, -) { - val appearanceSettings = LocalAppearanceSettings.current - mediaState.onSuccess { - if (itemCount > 0) { - ContextualFlowRow( - modifier = - modifier - .padding(horizontal = screenHorizontalPadding), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - overflow = - ContextualFlowRowOverflow.expandIndicator { - Box( - modifier = - Modifier - .size(itemSize) - .background( - color = - MaterialTheme.colorScheme - .surfaceColorAtElevation( - 3.dp, - ).copy(alpha = 0.25f), - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.mastodon_item_show_more), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } - }, - itemCount = itemCount, - maxLines = maxLines, - ) { - val item = get(it) - if (item == null) { - Box( - modifier = - Modifier - .aspectRatio(1f) - .size(itemSize) - .placeholder(true), - ) - } else { - Box { - MediaItem( - media = item.media, - modifier = - Modifier - .clipToBounds() - .size(itemSize) - .clip(MaterialTheme.shapes.medium) - .let { - if (item.media is UiMedia.Image && - (item.media as UiMedia.Image).sensitive && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - !appearanceSettings.showSensitiveContent - ) { - it.blur(32.dp) - } else { - it - } - }, - showCountdown = false, - ) - Box( - modifier = - Modifier - .matchParentSize() - .let { - if (item.media is UiMedia.Image && - (item.media as UiMedia.Image).sensitive && - Build.VERSION.SDK_INT < Build.VERSION_CODES.S && - !appearanceSettings.showSensitiveContent - ) { - it.background(MaterialTheme.colorScheme.surfaceContainer) - } else { - it - } - }, - ) - } - } - } - } - } -} - @Composable private fun profilePresenter( userKey: MicroBlogKey?, @@ -1412,10 +1401,22 @@ private fun profilePresenter( var showMoreMenus by remember { mutableStateOf(false) } + val mediaState = state.mediaState + + val profileTabs = + listOfNotNull( + ProfileTab.Timeline, + if (mediaState.isSuccess() && mediaState.itemCount > 0 || mediaState.isLoading) { + ProfileTab.Media + } else { + null + }, + ) object { val state = state val showMoreMenus = showMoreMenus val isRefreshing = isRefreshing + val profileTabs = profileTabs fun setShowMoreMenus(value: Boolean) { showMoreMenus = value @@ -1430,3 +1431,16 @@ private fun profilePresenter( } } } + +private enum class ProfileTab { + Timeline, + Media, +} + +private val ProfileTab.title: String + @Composable + get() = + when (this) { + ProfileTab.Timeline -> stringResource(R.string.profile_tab_timeline) + ProfileTab.Media -> stringResource(R.string.profile_tab_media) + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f9e65d02..b7fe22252 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Following Requested Blocked + Follows you Compose What\'s happening? @@ -407,4 +408,7 @@ Created by %1$s Unsubscribe Subscribe + + Timeline + Media \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 718c6267e..b3fc5188d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,7 @@ androidx-splash = { group = "androidx.core", name = "core-splashscreen", version room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +nestedScrollView = { group = "com.github.Tlaster", name = "NestedScrollView", version = "1.0.2" } media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }