From 8dc47c3fa56787a7cfb5d6f58bf8baeec3218fcf Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sun, 23 Mar 2025 19:53:58 +0300 Subject: [PATCH] separated screens for friends tab --- .../meloda/fast/presentation/MainScreen.kt | 1 - .../meloda/fast/friends/FriendsViewModel.kt | 208 +++++----- .../meloda/fast/friends/di/FriendsModule.kt | 4 +- .../fast/friends/model/FriendsScreenState.kt | 10 +- .../friends/navigation/FriendsNavigation.kt | 9 - .../friends/presentation/FriendsScreen.kt | 374 +++++------------- .../friends/presentation/RootFriendsScreen.kt | 187 +++++++++ gradle/libs.versions.toml | 2 +- 8 files changed, 386 insertions(+), 409 deletions(-) create mode 100644 feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt index bf93b82f..05a1fcab 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt @@ -165,7 +165,6 @@ fun MainScreen( ) { friendsScreen( onError = onError, - navController = navController, onPhotoClicked = onPhotoClicked, onMessageClicked = onMessageClicked ) diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt index bf953845..e17b28b6 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -// TODO: 13/07/2024, Danil Nikolaev: separate two lists and their pagination interface FriendsViewModel { val screenState: StateFlow @@ -33,19 +32,11 @@ interface FriendsViewModel { fun onErrorConsumed() - fun onTabSelected(tabIndex: Int) - fun setScrollIndex(index: Int) fun setScrollOffset(offset: Int) - fun setScrollIndexOnline(index: Int) - fun setScrollOffsetOnline(offset: Int) } -class FriendsViewModelImpl( - private val friendsUseCase: FriendsUseCase, - private val userSettings: UserSettings, - private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase -) : ViewModel(), FriendsViewModel { +abstract class BaseFriendsViewModelImpl : ViewModel(), FriendsViewModel { override val screenState = MutableStateFlow(FriendsScreenState.EMPTY) @@ -54,13 +45,7 @@ class FriendsViewModelImpl( override val currentOffset = MutableStateFlow(0) override val canPaginate = MutableStateFlow(false) - private val friends = MutableStateFlow>(emptyList()) - - init { - userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames) - - loadFriends() - } + protected val friends = MutableStateFlow>(emptyList()) override fun onPaginationConditionsMet() { currentOffset.update { screenState.value.friends.size } @@ -76,10 +61,6 @@ class FriendsViewModelImpl( baseError.setValue { null } } - override fun onTabSelected(tabIndex: Int) { - screenState.setValue { old -> old.copy(selectedTabIndex = tabIndex) } - } - override fun setScrollIndex(index: Int) { screenState.setValue { old -> old.copy(scrollIndex = index) } } @@ -88,38 +69,75 @@ class FriendsViewModelImpl( screenState.setValue { old -> old.copy(scrollOffset = offset) } } - override fun setScrollIndexOnline(index: Int) { - screenState.setValue { old -> old.copy(scrollIndexOnline = index) } - } + abstract fun loadFriends(offset: Int = currentOffset.value) - override fun setScrollOffsetOnline(offset: Int) { - screenState.setValue { old -> old.copy(scrollOffsetOnline = offset) } - } - - private fun loadFriends(offset: Int = currentOffset.value) { - friendsUseCase.getOnlineFriends(null, null) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { userIds -> - loadUsersByIdsUseCase(userIds = userIds) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { onlineFriends -> - screenState.setValue { old -> - old.copy( - onlineFriends = onlineFriends.map { - it.asPresentation(userSettings.useContactNames.value) - } - ) - } - } - ) - } + protected fun handleError(error: State.Error) { + when (error) { + is State.Error.ApiError -> { + when (error.errorCode) { + VkErrorCode.USER_AUTHORIZATION_FAILED -> { + baseError.setValue { BaseError.SessionExpired } } - ) + + else -> { + baseError.setValue { + BaseError.SimpleError(message = error.errorMessage) + } + } + } } + + State.Error.ConnectionError -> { + baseError.setValue { + BaseError.SimpleError(message = "Connection error") + } + } + + State.Error.InternalError -> { + baseError.setValue { + BaseError.SimpleError(message = "Internal error") + } + } + + State.Error.UnknownError -> { + baseError.setValue { + BaseError.SimpleError(message = "Unknown error") + } + } + + else -> Unit + } + } + + protected fun updateFriendsNames(useContactNames: Boolean) { + val friends = friends.value + if (friends.isEmpty()) return + + val uiFriends = friends.map { conversation -> + conversation.asPresentation(useContactNames) + } + + screenState.setValue { old -> + old.copy(friends = uiFriends) + } + } + + companion object { + const val LOAD_COUNT = 30 + } +} + +class FriendsViewModelImpl( + private val friendsUseCase: FriendsUseCase, + private val userSettings: UserSettings +) : BaseFriendsViewModelImpl() { + + init { + userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames) + loadFriends() + } + + override fun loadFriends(offset: Int) { friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset) .listenValue(viewModelScope) { state -> state.processState( @@ -167,62 +185,48 @@ class FriendsViewModelImpl( } } } +} - private fun handleError(error: State.Error) { - when (error) { - is State.Error.ApiError -> { - when (error.errorCode) { - VkErrorCode.USER_AUTHORIZATION_FAILED -> { - baseError.setValue { BaseError.SessionExpired } - } +class OnlineFriendsViewModelImpl( + private val friendsUseCase: FriendsUseCase, + private val userSettings: UserSettings, + private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase +) : BaseFriendsViewModelImpl() { - else -> { - baseError.setValue { - BaseError.SimpleError(message = error.errorMessage) + init { + userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames) + loadFriends() + } + + override fun loadFriends(offset: Int) { + friendsUseCase.getOnlineFriends(null, null) + .listenValue(viewModelScope) { onlineState -> + onlineState.processState( + error = ::handleError, + success = { userIds -> + loadUsersByIdsUseCase(userIds = userIds).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { onlineFriends -> + screenState.setValue { old -> + old.copy( + friends = onlineFriends.map { + it.asPresentation(userSettings.useContactNames.value) + } + ) + } + } + ) + + screenState.setValue { old -> + old.copy( + isLoading = offset == 0 && (onlineState.isLoading() || state.isLoading()), + isPaginating = offset > 0 && (onlineState.isLoading() || state.isLoading()) + ) + } } } - } + ) } - State.Error.ConnectionError -> { - baseError.setValue { - BaseError.SimpleError(message = "Connection error") - } - } - State.Error.InternalError -> { - baseError.setValue { - BaseError.SimpleError(message = "Internal error") - } - } - State.Error.UnknownError -> { - baseError.setValue { - BaseError.SimpleError(message = "Unknown error") - } - } - else -> Unit - } - } - - private fun updateFriendsNames(useContactNames: Boolean) { - val friends = friends.value - if (friends.isEmpty()) return - - val uiFriends = friends.map { conversation -> - conversation.asPresentation(useContactNames) - } - - val onlineUiFriends = screenState.value.onlineFriends.mapNotNull { friend -> - uiFriends.find { it.userId == friend.userId } - } - - screenState.setValue { old -> - old.copy( - friends = uiFriends, - onlineFriends = onlineUiFriends - ) - } - } - - companion object { - const val LOAD_COUNT = 15 } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt index c19b8cd4..8014e373 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/di/FriendsModule.kt @@ -1,8 +1,9 @@ package dev.meloda.fast.friends.di import dev.meloda.fast.domain.FriendsUseCase -import dev.meloda.fast.friends.FriendsViewModelImpl import dev.meloda.fast.domain.FriendsUseCaseImpl +import dev.meloda.fast.friends.FriendsViewModelImpl +import dev.meloda.fast.friends.OnlineFriendsViewModelImpl import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.bind @@ -11,4 +12,5 @@ import org.koin.dsl.module val friendsModule = module { singleOf(::FriendsUseCaseImpl) bind FriendsUseCase::class viewModelOf(::FriendsViewModelImpl) + viewModelOf(::OnlineFriendsViewModelImpl) } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt index f2583c3a..4664edfb 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt @@ -7,28 +7,20 @@ import dev.meloda.fast.ui.model.api.UiFriend data class FriendsScreenState( val isLoading: Boolean, val friends: List, - val onlineFriends: List, val isPaginating: Boolean, val isPaginationExhausted: Boolean, - val selectedTabIndex: Int, val scrollIndex: Int, val scrollOffset: Int, - val scrollIndexOnline: Int, - val scrollOffsetOnline: Int ) { companion object { val EMPTY: FriendsScreenState = FriendsScreenState( isLoading = true, friends = emptyList(), - onlineFriends = emptyList(), isPaginating = false, isPaginationExhausted = false, - selectedTabIndex = 0, scrollIndex = 0, - scrollOffset = 0, - scrollIndexOnline = 0, - scrollOffsetOnline = 0, + scrollOffset = 0 ) } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt index 9fdaa30e..1612a7a7 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt @@ -1,13 +1,9 @@ package dev.meloda.fast.friends.navigation -import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import dev.meloda.fast.friends.FriendsViewModel -import dev.meloda.fast.friends.FriendsViewModelImpl import dev.meloda.fast.friends.presentation.FriendsRoute import dev.meloda.fast.model.BaseError -import dev.meloda.fast.ui.extensions.sharedViewModel import kotlinx.serialization.Serializable @Serializable @@ -15,17 +11,12 @@ object Friends fun NavGraphBuilder.friendsScreen( onError: (BaseError) -> Unit, - navController: NavController, onPhotoClicked: (url: String) -> Unit, onMessageClicked: (userId: Int) -> Unit ) { composable { - val viewModel: FriendsViewModel = - it.sharedViewModel(navController = navController) - FriendsRoute( onError = onError, - viewModel = viewModel, onPhotoClicked = onPhotoClicked, onMessageClicked = onMessageClicked ) diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt index d3560226..6e403c8b 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt @@ -1,81 +1,64 @@ package dev.meloda.fast.friends.presentation -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets +import android.content.Context +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.imageLoader import coil.request.ImageRequest -import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials import dev.meloda.fast.friends.FriendsViewModel import dev.meloda.fast.friends.FriendsViewModelImpl -import dev.meloda.fast.friends.model.FriendsScreenState +import dev.meloda.fast.friends.OnlineFriendsViewModelImpl import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.NoItemsView -import dev.meloda.fast.ui.model.TabItem import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import org.koin.androidx.compose.koinViewModel -import dev.meloda.fast.ui.R as UiR +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun FriendsRoute( - onError: (BaseError) -> Unit, - onPhotoClicked: (url: String) -> Unit, - onMessageClicked: (userId: Int) -> Unit, - viewModel: FriendsViewModel = koinViewModel() +fun FriendsScreen( + modifier: Modifier = Modifier, + padding: PaddingValues, + tabIndex: Int, + onSessionExpiredLogOutButtonClicked: () -> Unit = {}, + onPhotoClicked: (url: String) -> Unit = {}, + onMessageClicked: (userId: Int) -> Unit = {}, + setCanScrollBackward: (Boolean) -> Unit = {} ) { - val context = LocalContext.current + val context: Context = LocalContext.current + val viewModel: FriendsViewModel = + if (tabIndex == 0) { + koinViewModel() + } else { + koinViewModel() + } val screenState by viewModel.screenState.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() @@ -92,43 +75,6 @@ fun FriendsRoute( } } - FriendsScreen( - screenState = screenState, - baseError = baseError, - canPaginate = canPaginate, - onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, - onPaginationConditionsMet = viewModel::onPaginationConditionsMet, - onRefresh = viewModel::onRefresh, - onPhotoClicked = onPhotoClicked, - onMessageClicked = onMessageClicked, - setSelectedTabIndex = viewModel::onTabSelected, - setScrollIndex = viewModel::setScrollIndex, - setScrollOffset = viewModel::setScrollOffset, - setScrollIndexOnline = viewModel::setScrollIndexOnline, - setScrollOffsetOnline = viewModel::setScrollOffsetOnline - ) -} - -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalHazeMaterialsApi::class -) -@Composable -fun FriendsScreen( - screenState: FriendsScreenState = FriendsScreenState.EMPTY, - baseError: BaseError? = null, - canPaginate: Boolean = false, - onSessionExpiredLogOutButtonClicked: () -> Unit = {}, - onPaginationConditionsMet: () -> Unit = {}, - onRefresh: () -> Unit = {}, - onPhotoClicked: (url: String) -> Unit = {}, - onMessageClicked: (userId: Int) -> Unit = {}, - setSelectedTabIndex: (Int) -> Unit = {}, - setScrollIndex: (Int) -> Unit = {}, - setScrollOffset: (Int) -> Unit = {}, - setScrollIndexOnline: (Int) -> Unit = {}, - setScrollOffsetOnline: (Int) -> Unit = {} -) { val currentTheme = LocalThemeConfig.current val maxLines by remember { @@ -141,33 +87,17 @@ fun FriendsScreen( initialFirstVisibleItemIndex = screenState.scrollIndex, initialFirstVisibleItemScrollOffset = screenState.scrollOffset ) - val listStateOnline = rememberLazyListState( - initialFirstVisibleItemIndex = screenState.scrollIndexOnline, - initialFirstVisibleItemScrollOffset = screenState.scrollOffsetOnline - ) LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } - .debounce(500L) - .collectLatest(setScrollIndex) + .debounce(250L) + .collectLatest(viewModel::setScrollIndex) } LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemScrollOffset } - .debounce(500L) - .collectLatest(setScrollOffset) - } - - LaunchedEffect(listStateOnline) { - snapshotFlow { listStateOnline.firstVisibleItemIndex } - .debounce(500L) - .collectLatest(setScrollIndexOnline) - } - - LaunchedEffect(listStateOnline) { - snapshotFlow { listStateOnline.firstVisibleItemScrollOffset } - .debounce(500L) - .collectLatest(setScrollOffsetOnline) + .debounce(250L) + .collectLatest(viewModel::setScrollOffset) } val paginationConditionMet by remember(canPaginate, listState) { @@ -180,209 +110,81 @@ fun FriendsScreen( LaunchedEffect(paginationConditionMet) { if (paginationConditionMet && !screenState.isPaginating) { - onPaginationConditionsMet() + viewModel.onPaginationConditionsMet() } } val hazeState = LocalHazeState.current - var canScrollBackward by remember { - mutableStateOf(false) - } - - val topBarContainerColorAlpha by animateFloatAsState( - targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f, - label = "toolbarColorAlpha", - animationSpec = tween( - durationMillis = 200, - easing = FastOutLinearInEasing - ) - ) - - val topBarContainerColor by animateColorAsState( - targetValue = if (currentTheme.enableBlur || !canScrollBackward) - MaterialTheme.colorScheme.surface - else - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - label = "toolbarColorAlpha", - animationSpec = tween( - durationMillis = 200, - easing = FastOutLinearInEasing - ) - ) - - val tabItems = remember { - listOf( - TabItem( - titleResId = UiR.string.title_friends_all, - unselectedIconResId = null, - selectedIconResId = null - ), - TabItem( - titleResId = UiR.string.title_friends_online, - unselectedIconResId = null, - selectedIconResId = null - ) - ) - } - - Scaffold( - modifier = Modifier.fillMaxSize(), - contentWindowInsets = WindowInsets.statusBars, - topBar = { - Column( - modifier = Modifier - .then( - if (currentTheme.enableBlur) { - Modifier.hazeEffect( - state = hazeState, - style = HazeMaterials.thick() - ) - } else { - Modifier - } - ) - .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) - .fillMaxWidth() - ) { - TopAppBar( - title = { - Text( - text = stringResource(id = UiR.string.title_friends), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.headlineSmall - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.fillMaxWidth() + baseError?.let { error -> + when (error) { + is BaseError.SessionExpired -> { + ErrorView( + text = stringResource(R.string.session_expired), + buttonText = stringResource(R.string.action_log_out), + onButtonClick = onSessionExpiredLogOutButtonClicked + ) + } + + is BaseError.SimpleError -> { + ErrorView( + text = error.message, + buttonText = stringResource(R.string.try_again), + onButtonClick = viewModel::onRefresh ) - PrimaryTabRow( - selectedTabIndex = screenState.selectedTabIndex, - modifier = Modifier, - containerColor = Color.Transparent - ) { - tabItems.forEachIndexed { index, item -> - Tab( - selected = index == screenState.selectedTabIndex, - onClick = { - if (screenState.selectedTabIndex != index) { - setSelectedTabIndex(index) - } - }, - text = { - item.titleResId?.let { resId -> - Text(text = stringResource(id = resId)) - } - } - ) - } - } } } - ) { padding -> - when { - baseError != null -> { - when (baseError) { - is BaseError.SessionExpired -> { - ErrorView( - text = stringResource(UiR.string.session_expired), - buttonText = stringResource(UiR.string.action_log_out), - onButtonClick = onSessionExpiredLogOutButtonClicked - ) - } - is BaseError.SimpleError -> { - ErrorView( - text = baseError.message, - buttonText = stringResource(UiR.string.try_again), - onButtonClick = onRefresh - ) - } + return + } + + when { + screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() + + else -> { + val pullToRefreshState = rememberPullToRefreshState() + + PullToRefreshBox( + modifier = modifier + .fillMaxSize() + .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) + .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) + .padding(bottom = padding.calculateBottomPadding()), + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + onRefresh = viewModel::onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = screenState.isLoading, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()), + ) } - } + ) { + FriendsList( + modifier = if (currentTheme.enableBlur) { + Modifier.hazeSource(state = hazeState) + } else { + Modifier + }.fillMaxSize(), + screenState = screenState, + uiFriends = ImmutableList.copyOf(screenState.friends), + listState = listState, + maxLines = maxLines, + padding = padding, + onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked, + setCanScrollBackward = setCanScrollBackward + ) - screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() - - else -> { - val pagerState = rememberPagerState( - initialPage = screenState.selectedTabIndex - ) { - tabItems.size - } - - LaunchedEffect(screenState.selectedTabIndex) { - pagerState.animateScrollToPage(screenState.selectedTabIndex) - } - - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage } - .collect(setSelectedTabIndex) - } - - val pullToRefreshState = rememberPullToRefreshState() - - Box(modifier = Modifier.fillMaxSize()) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { index -> - PullToRefreshBox( - modifier = Modifier - .fillMaxSize() - .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) - .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) - .padding(bottom = padding.calculateBottomPadding()), - state = pullToRefreshState, - isRefreshing = screenState.isLoading, - onRefresh = onRefresh, - indicator = { - PullToRefreshDefaults.Indicator( - state = pullToRefreshState, - isRefreshing = screenState.isLoading, - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = padding.calculateTopPadding()), - ) - } - ) { - val friendsToDisplay = remember(index) { - if (index == 0) { - screenState.friends - } else { - screenState.onlineFriends - } - } - - FriendsList( - modifier = if (currentTheme.enableBlur) { - Modifier.hazeSource(state = hazeState) - } else { - Modifier - }.fillMaxSize(), - screenState = screenState, - uiFriends = ImmutableList.copyOf(friendsToDisplay), - listState = if (index == 0) listState else listStateOnline, - maxLines = maxLines, - padding = padding, - onPhotoClicked = onPhotoClicked, - onMessageClicked = onMessageClicked, - setCanScrollBackward = { can -> - canScrollBackward = can - } - ) - - if (friendsToDisplay.isEmpty()) { - NoItemsView( - customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null, - buttonText = stringResource(UiR.string.action_refresh), - onButtonClick = onRefresh - ) - } - } - } + if (screenState.friends.isEmpty()) { + NoItemsView( + customText = if (tabIndex == 1) stringResource(R.string.no_online_friends) else null, + buttonText = stringResource(R.string.action_refresh), + onButtonClick = viewModel::onRefresh + ) } } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt new file mode 100644 index 00000000..cf4a108c --- /dev/null +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/RootFriendsScreen.kt @@ -0,0 +1,187 @@ +package dev.meloda.fast.friends.presentation + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.model.TabItem +import dev.meloda.fast.ui.theme.LocalHazeState +import dev.meloda.fast.ui.theme.LocalThemeConfig + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +fun FriendsRoute( + onError: (BaseError) -> Unit, + onPhotoClicked: (url: String) -> Unit, + onMessageClicked: (userId: Int) -> Unit, +) { + var selectedTabIndex by rememberSaveable { + mutableIntStateOf(0) + } + + val currentTheme = LocalThemeConfig.current + val hazeState = LocalHazeState.current + + var canScrollBackward by remember { + mutableStateOf(false) + } + + val topBarContainerColorAlpha by animateFloatAsState( + targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f, + label = "toolbarColorAlpha", + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) + ) + + val topBarContainerColor by animateColorAsState( + targetValue = if (currentTheme.enableBlur || !canScrollBackward) + MaterialTheme.colorScheme.surface + else + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + label = "toolbarColorAlpha", + animationSpec = tween( + durationMillis = 200, + easing = FastOutLinearInEasing + ) + ) + + val tabItems = remember { + listOf( + TabItem( + titleResId = R.string.title_friends_all, + unselectedIconResId = null, + selectedIconResId = null + ), + TabItem( + titleResId = R.string.title_friends_online, + unselectedIconResId = null, + selectedIconResId = null + ) + ) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.statusBars, + topBar = { + Column( + modifier = Modifier + .then( + if (currentTheme.enableBlur) { + Modifier.hazeEffect( + state = hazeState, + style = HazeMaterials.thick() + ) + } else { + Modifier + } + ) + .background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) + .fillMaxWidth() + ) { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.title_friends), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineSmall + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth() + ) + PrimaryTabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier, + containerColor = Color.Transparent + ) { + tabItems.forEachIndexed { index, item -> + Tab( + selected = index == selectedTabIndex, + onClick = { + if (selectedTabIndex != index) { + selectedTabIndex = index + } + }, + text = { + item.titleResId?.let { resId -> + Text(text = stringResource(id = resId)) + } + } + ) + } + } + } + } + ) { padding -> + val pagerState = rememberPagerState( + initialPage = selectedTabIndex + ) { + tabItems.size + } + + LaunchedEffect(selectedTabIndex) { + pagerState.animateScrollToPage(selectedTabIndex) + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect { selectedTabIndex = it } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { index -> + FriendsScreen( + padding = padding, + tabIndex = index, + onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, + onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked, + setCanScrollBackward = { canScrollBackward = it } + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 643477f7..cbaa0436 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ minSdk = "23" targetSdk = "35" compileSdk = "35" versionCode = "9" -versionName = "0.1.6" +versionName = "0.1.9" agp = "8.9.0" converterMoshi = "2.11.0"