separated screens for friends tab

This commit is contained in:
2025-03-23 19:53:58 +03:00
parent 0eb3146428
commit 8dc47c3fa5
8 changed files with 386 additions and 409 deletions
@@ -165,7 +165,6 @@ fun MainScreen(
) { ) {
friendsScreen( friendsScreen(
onError = onError, onError = onError,
navController = navController,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked onMessageClicked = onMessageClicked
) )
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
// TODO: 13/07/2024, Danil Nikolaev: separate two lists and their pagination
interface FriendsViewModel { interface FriendsViewModel {
val screenState: StateFlow<FriendsScreenState> val screenState: StateFlow<FriendsScreenState>
@@ -33,19 +32,11 @@ interface FriendsViewModel {
fun onErrorConsumed() fun onErrorConsumed()
fun onTabSelected(tabIndex: Int)
fun setScrollIndex(index: Int) fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int) fun setScrollOffset(offset: Int)
fun setScrollIndexOnline(index: Int)
fun setScrollOffsetOnline(offset: Int)
} }
class FriendsViewModelImpl( abstract class BaseFriendsViewModelImpl : ViewModel(), FriendsViewModel {
private val friendsUseCase: FriendsUseCase,
private val userSettings: UserSettings,
private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase
) : ViewModel(), FriendsViewModel {
override val screenState = MutableStateFlow(FriendsScreenState.EMPTY) override val screenState = MutableStateFlow(FriendsScreenState.EMPTY)
@@ -54,13 +45,7 @@ class FriendsViewModelImpl(
override val currentOffset = MutableStateFlow(0) override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) override val canPaginate = MutableStateFlow(false)
private val friends = MutableStateFlow<List<VkUser>>(emptyList()) protected val friends = MutableStateFlow<List<VkUser>>(emptyList())
init {
userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames)
loadFriends()
}
override fun onPaginationConditionsMet() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size } currentOffset.update { screenState.value.friends.size }
@@ -76,10 +61,6 @@ class FriendsViewModelImpl(
baseError.setValue { null } baseError.setValue { null }
} }
override fun onTabSelected(tabIndex: Int) {
screenState.setValue { old -> old.copy(selectedTabIndex = tabIndex) }
}
override fun setScrollIndex(index: Int) { override fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) } screenState.setValue { old -> old.copy(scrollIndex = index) }
} }
@@ -88,38 +69,75 @@ class FriendsViewModelImpl(
screenState.setValue { old -> old.copy(scrollOffset = offset) } screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
override fun setScrollIndexOnline(index: Int) { abstract fun loadFriends(offset: Int = currentOffset.value)
screenState.setValue { old -> old.copy(scrollIndexOnline = index) }
protected fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
} }
override fun setScrollOffsetOnline(offset: Int) { else -> {
screenState.setValue { old -> old.copy(scrollOffsetOnline = offset) } 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)
} }
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 -> screenState.setValue { old ->
old.copy( old.copy(friends = uiFriends)
onlineFriends = onlineFriends.map {
it.asPresentation(userSettings.useContactNames.value)
}
)
} }
} }
)
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) friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( 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 }
} }
else -> { class OnlineFriendsViewModelImpl(
baseError.setValue { private val friendsUseCase: FriendsUseCase,
BaseError.SimpleError(message = error.errorMessage) private val userSettings: UserSettings,
} private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase
} ) : BaseFriendsViewModelImpl() {
}
} init {
State.Error.ConnectionError -> { userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames)
baseError.setValue { loadFriends()
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 }
} }
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 -> screenState.setValue { old ->
old.copy( old.copy(
friends = uiFriends, friends = onlineFriends.map {
onlineFriends = onlineUiFriends it.asPresentation(userSettings.useContactNames.value)
}
) )
} }
} }
)
companion object { screenState.setValue { old ->
const val LOAD_COUNT = 15 old.copy(
isLoading = offset == 0 && (onlineState.isLoading() || state.isLoading()),
isPaginating = offset > 0 && (onlineState.isLoading() || state.isLoading())
)
}
}
}
)
}
} }
} }
@@ -1,8 +1,9 @@
package dev.meloda.fast.friends.di package dev.meloda.fast.friends.di
import dev.meloda.fast.domain.FriendsUseCase import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.friends.FriendsViewModelImpl
import dev.meloda.fast.domain.FriendsUseCaseImpl 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.singleOf
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind import org.koin.dsl.bind
@@ -11,4 +12,5 @@ import org.koin.dsl.module
val friendsModule = module { val friendsModule = module {
singleOf(::FriendsUseCaseImpl) bind FriendsUseCase::class singleOf(::FriendsUseCaseImpl) bind FriendsUseCase::class
viewModelOf(::FriendsViewModelImpl) viewModelOf(::FriendsViewModelImpl)
viewModelOf(::OnlineFriendsViewModelImpl)
} }
@@ -7,28 +7,20 @@ import dev.meloda.fast.ui.model.api.UiFriend
data class FriendsScreenState( data class FriendsScreenState(
val isLoading: Boolean, val isLoading: Boolean,
val friends: List<UiFriend>, val friends: List<UiFriend>,
val onlineFriends: List<UiFriend>,
val isPaginating: Boolean, val isPaginating: Boolean,
val isPaginationExhausted: Boolean, val isPaginationExhausted: Boolean,
val selectedTabIndex: Int,
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val scrollIndexOnline: Int,
val scrollOffsetOnline: Int
) { ) {
companion object { companion object {
val EMPTY: FriendsScreenState = FriendsScreenState( val EMPTY: FriendsScreenState = FriendsScreenState(
isLoading = true, isLoading = true,
friends = emptyList(), friends = emptyList(),
onlineFriends = emptyList(),
isPaginating = false, isPaginating = false,
isPaginationExhausted = false, isPaginationExhausted = false,
selectedTabIndex = 0,
scrollIndex = 0, scrollIndex = 0,
scrollOffset = 0, scrollOffset = 0
scrollIndexOnline = 0,
scrollOffsetOnline = 0,
) )
} }
} }
@@ -1,13 +1,9 @@
package dev.meloda.fast.friends.navigation package dev.meloda.fast.friends.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable 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.friends.presentation.FriendsRoute
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -15,17 +11,12 @@ object Friends
fun NavGraphBuilder.friendsScreen( fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
navController: NavController,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit onMessageClicked: (userId: Int) -> Unit
) { ) {
composable<Friends> { composable<Friends> {
val viewModel: FriendsViewModel =
it.sharedViewModel<FriendsViewModelImpl>(navController = navController)
FriendsRoute( FriendsRoute(
onError = onError, onError = onError,
viewModel = viewModel,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked onMessageClicked = onMessageClicked
) )
@@ -1,81 +1,64 @@
package dev.meloda.fast.friends.presentation package dev.meloda.fast.friends.presentation
import androidx.compose.animation.animateColorAsState import android.content.Context
import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.foundation.layout.PaddingValues
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 androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState 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.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.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource 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.FriendsViewModel
import dev.meloda.fast.friends.FriendsViewModelImpl 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.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView 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.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FriendsRoute( fun FriendsScreen(
onError: (BaseError) -> Unit, modifier: Modifier = Modifier,
onPhotoClicked: (url: String) -> Unit, padding: PaddingValues,
onMessageClicked: (userId: Int) -> Unit, tabIndex: Int,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>() 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<FriendsViewModelImpl>()
} else {
koinViewModel<OnlineFriendsViewModelImpl>()
}
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.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 currentTheme = LocalThemeConfig.current
val maxLines by remember { val maxLines by remember {
@@ -141,33 +87,17 @@ fun FriendsScreen(
initialFirstVisibleItemIndex = screenState.scrollIndex, initialFirstVisibleItemIndex = screenState.scrollIndex,
initialFirstVisibleItemScrollOffset = screenState.scrollOffset initialFirstVisibleItemScrollOffset = screenState.scrollOffset
) )
val listStateOnline = rememberLazyListState(
initialFirstVisibleItemIndex = screenState.scrollIndexOnline,
initialFirstVisibleItemScrollOffset = screenState.scrollOffsetOnline
)
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L) .debounce(250L)
.collectLatest(setScrollIndex) .collectLatest(viewModel::setScrollIndex)
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L) .debounce(250L)
.collectLatest(setScrollOffset) .collectLatest(viewModel::setScrollOffset)
}
LaunchedEffect(listStateOnline) {
snapshotFlow { listStateOnline.firstVisibleItemIndex }
.debounce(500L)
.collectLatest(setScrollIndexOnline)
}
LaunchedEffect(listStateOnline) {
snapshotFlow { listStateOnline.firstVisibleItemScrollOffset }
.debounce(500L)
.collectLatest(setScrollOffsetOnline)
} }
val paginationConditionMet by remember(canPaginate, listState) { val paginationConditionMet by remember(canPaginate, listState) {
@@ -180,164 +110,49 @@ fun FriendsScreen(
LaunchedEffect(paginationConditionMet) { LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) { if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet() viewModel.onPaginationConditionsMet()
} }
} }
val hazeState = LocalHazeState.current val hazeState = LocalHazeState.current
var canScrollBackward by remember { baseError?.let { error ->
mutableStateOf(false) when (error) {
}
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()
)
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 -> { is BaseError.SessionExpired -> {
ErrorView( ErrorView(
text = stringResource(UiR.string.session_expired), text = stringResource(R.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out), buttonText = stringResource(R.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked onButtonClick = onSessionExpiredLogOutButtonClicked
) )
} }
is BaseError.SimpleError -> { is BaseError.SimpleError -> {
ErrorView( ErrorView(
text = baseError.message, text = error.message,
buttonText = stringResource(UiR.string.try_again), buttonText = stringResource(R.string.try_again),
onButtonClick = onRefresh onButtonClick = viewModel::onRefresh
) )
} }
} }
return
} }
when {
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
else -> { 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() val pullToRefreshState = rememberPullToRefreshState()
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
PullToRefreshBox( PullToRefreshBox(
modifier = Modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr)) .padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)) .padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding()), .padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = screenState.isLoading, isRefreshing = screenState.isLoading,
onRefresh = onRefresh, onRefresh = viewModel::onRefresh,
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = pullToRefreshState, state = pullToRefreshState,
@@ -348,14 +163,6 @@ fun FriendsScreen(
) )
} }
) { ) {
val friendsToDisplay = remember(index) {
if (index == 0) {
screenState.friends
} else {
screenState.onlineFriends
}
}
FriendsList( FriendsList(
modifier = if (currentTheme.enableBlur) { modifier = if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState) Modifier.hazeSource(state = hazeState)
@@ -363,28 +170,23 @@ fun FriendsScreen(
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
screenState = screenState, screenState = screenState,
uiFriends = ImmutableList.copyOf(friendsToDisplay), uiFriends = ImmutableList.copyOf(screenState.friends),
listState = if (index == 0) listState else listStateOnline, listState = listState,
maxLines = maxLines, maxLines = maxLines,
padding = padding, padding = padding,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
setCanScrollBackward = { can -> setCanScrollBackward = setCanScrollBackward
canScrollBackward = can
}
) )
if (friendsToDisplay.isEmpty()) { if (screenState.friends.isEmpty()) {
NoItemsView( NoItemsView(
customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null, customText = if (tabIndex == 1) stringResource(R.string.no_online_friends) else null,
buttonText = stringResource(UiR.string.action_refresh), buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh onButtonClick = viewModel::onRefresh
) )
} }
} }
} }
} }
} }
}
}
}
@@ -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 }
)
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ minSdk = "23"
targetSdk = "35" targetSdk = "35"
compileSdk = "35" compileSdk = "35"
versionCode = "9" versionCode = "9"
versionName = "0.1.6" versionName = "0.1.9"
agp = "8.9.0" agp = "8.9.0"
converterMoshi = "2.11.0" converterMoshi = "2.11.0"