Release 0.2.0 (#150)

Release Notes

* Bumped haze, agp, and guava dependencies
* Implemented ordering functionality for friends list
* Added scroll to top feature in friends and conversations screens
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Implemented logout functionality
* Implemented new authorization flow (no auto-token re-request)
* Added support for sticker pack preview attachments
* Bump LongPoll to version 19
* Markdown support for messages bubbles
* Adjust app name font size based on screen width

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-04-04 21:47:05 +03:00
committed by GitHub
parent 0eb3146428
commit 82fb78e9ea
279 changed files with 9171 additions and 4517 deletions
@@ -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<FriendsScreenState>
@@ -33,19 +32,13 @@ interface FriendsViewModel {
fun onErrorConsumed()
fun onTabSelected(tabIndex: Int)
fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int)
fun setScrollIndexOnline(index: Int)
fun setScrollOffsetOnline(offset: Int)
fun onOrderTypeChanged(newOrderType: String)
}
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 +47,7 @@ class FriendsViewModelImpl(
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
private val friends = MutableStateFlow<List<VkUser>>(emptyList())
init {
userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames)
loadFriends()
}
protected val friends = MutableStateFlow<List<VkUser>>(emptyList())
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size }
@@ -76,10 +63,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,87 +71,15 @@ class FriendsViewModelImpl(
screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
override fun setScrollIndexOnline(index: Int) {
screenState.setValue { old -> old.copy(scrollIndexOnline = index) }
override fun onOrderTypeChanged(newOrderType: String) {
if (screenState.value.orderType == newOrderType) return
screenState.setValue { old -> old.copy(orderType = newOrderType) }
loadFriends(offset = 0)
}
override fun setScrollOffsetOnline(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffsetOnline = offset) }
}
abstract fun loadFriends(offset: Int = currentOffset.value)
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)
}
)
}
}
)
}
}
)
}
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.friends.size >= LOAD_COUNT
imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(userSettings.useContactNames.value)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
friends.emit(response)
screenState.setValue {
newState.copy(friends = loadedFriends)
}
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(friends = newState.friends.plus(loadedFriends))
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun handleError(error: State.Error) {
protected fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
@@ -183,26 +94,30 @@ class FriendsViewModelImpl(
}
}
}
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) {
protected fun updateFriendsNames(useContactNames: Boolean) {
val friends = friends.value
if (friends.isEmpty()) return
@@ -210,19 +125,119 @@ class FriendsViewModelImpl(
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
)
old.copy(friends = uiFriends)
}
}
companion object {
const val LOAD_COUNT = 15
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(
order = screenState.value.orderType,
count = LOAD_COUNT,
offset = offset
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient
&& screenState.value.friends.isNotEmpty()
imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(userSettings.useContactNames.value)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
friends.emit(response)
screenState.setValue {
newState.copy(friends = loadedFriends)
}
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(friends = newState.friends.plus(loadedFriends))
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
}
class OnlineFriendsViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val userSettings: UserSettings,
private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase
) : BaseFriendsViewModelImpl() {
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())
)
}
}
}
)
}
}
}
@@ -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)
}
@@ -7,28 +7,22 @@ import dev.meloda.fast.ui.model.api.UiFriend
data class FriendsScreenState(
val isLoading: Boolean,
val friends: List<UiFriend>,
val onlineFriends: List<UiFriend>,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val selectedTabIndex: Int,
val scrollIndex: Int,
val scrollOffset: Int,
val scrollIndexOnline: Int,
val scrollOffsetOnline: Int
val orderType: String,
) {
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,
orderType = "hints"
)
}
}
@@ -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,19 +11,16 @@ object Friends
fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit,
navController: NavController,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
onMessageClicked: (userid: Long) -> Unit,
onScrolledToTop: () -> Unit
) {
composable<Friends> {
val viewModel: FriendsViewModel =
it.sharedViewModel<FriendsViewModelImpl>(navController = navController)
FriendsRoute(
onError = onError,
viewModel = viewModel,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
onMessageClicked = onMessageClicked,
onScrolledToTop = onScrolledToTop
)
}
}
@@ -36,7 +36,7 @@ fun FriendItem(
friend: UiFriend,
maxLines: Int,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
onMessageClicked: (userid: Long) -> Unit
) {
Row(
modifier = modifier.fillMaxWidth(),
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -36,7 +37,7 @@ fun FriendsList(
maxLines: Int,
padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
onMessageClicked: (userid: Long) -> Unit,
setCanScrollBackward: (Boolean) -> Unit
) {
LaunchedEffect(listState) {
@@ -46,8 +47,6 @@ fun FriendsList(
val coroutineScope = rememberCoroutineScope()
val friends = uiFriends.toList()
LazyColumn(
modifier = modifier,
state = listState
@@ -58,7 +57,7 @@ fun FriendsList(
}
items(
items = friends,
items = uiFriends.toList(),
key = UiFriend::userId,
) { friend ->
FriendItem(
@@ -100,6 +99,7 @@ fun FriendsList(
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
@@ -1,81 +1,72 @@
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.appcompat.app.AppCompatActivity
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.model.BaseError
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.friends.OnlineFriendsViewModelImpl
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.ui.R
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.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab
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<FriendsViewModelImpl>()
fun FriendsScreen(
modifier: Modifier = Modifier,
orderType: String,
padding: PaddingValues,
tabIndex: Int,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {},
setCanScrollBackward: (Boolean) -> Unit = {},
onScrolledToTop: () -> Unit = {}
) {
val context = LocalContext.current
val context: Context = LocalContext.current
val viewModel: FriendsViewModel =
if (tabIndex == 0) {
koinViewModel<FriendsViewModelImpl>(viewModelStoreOwner = context as AppCompatActivity)
} else {
koinViewModel<OnlineFriendsViewModelImpl>(viewModelStoreOwner = context as AppCompatActivity)
}
LaunchedEffect(orderType) {
viewModel.onOrderTypeChanged(orderType)
}
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
@@ -92,43 +83,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 +95,28 @@ fun FriendsScreen(
initialFirstVisibleItemIndex = screenState.scrollIndex,
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
val listStateOnline = rememberLazyListState(
initialFirstVisibleItemIndex = screenState.scrollIndexOnline,
initialFirstVisibleItemScrollOffset = screenState.scrollOffsetOnline
)
val scrollToTop = LocalReselectedTab.current[Friends] ?: false
LaunchedEffect(scrollToTop) {
if (scrollToTop) {
if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14)
}
listState.animateScrollToItem(0)
onScrolledToTop()
}
}
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 +129,64 @@ fun FriendsScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
viewModel.onPaginationConditionsMet()
}
}
val hazeState = LocalHazeState.current
var canScrollBackward by remember {
mutableStateOf(false)
baseError?.let { error ->
VkErrorView(baseError = error)
return
}
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
when {
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
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
)
)
else -> {
val pullToRefreshState = rememberPullToRefreshState()
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
}
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()),
)
.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()
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
)
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
)
}
}
}
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
)
}
}
}
@@ -0,0 +1,245 @@
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.Icon
import androidx.compose.material3.IconButton
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.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
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.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SelectionType
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.launch
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun FriendsRoute(
onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userid: Long) -> Unit,
onScrolledToTop: () -> Unit
) {
val scope = rememberCoroutineScope()
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
)
)
}
val pagerState = rememberPagerState(pageCount = tabItems::size)
val selectedTabIndex by remember {
derivedStateOf { pagerState.currentPage }
}
var orderType: String by remember { mutableStateOf("hints") }
var showOrderDialog by remember { mutableStateOf(false) }
val orderPriority = stringResource(UiR.string.friends_order_priority)
val orderName = stringResource(UiR.string.friends_order_name)
val orderRandom = stringResource(UiR.string.friends_order_random)
val orderMobile = stringResource(UiR.string.friends_order_mobile)
val orderSmart = stringResource(UiR.string.friends_order_smart)
val orderTitleItems = remember {
ImmutableList.of(
orderPriority,
orderName,
orderRandom,
orderMobile,
orderSmart
)
}
val orderItems = remember {
listOf("hints", "name", "random", "mobile", "smart")
}
var selectedIndex by remember {
mutableIntStateOf(0)
}
if (showOrderDialog) {
MaterialDialog(
onDismissRequest = { showOrderDialog = false },
confirmText = stringResource(R.string.ok),
confirmAction = {
orderType = orderItems[selectedIndex]
},
cancelText = stringResource(R.string.cancel),
selectionType = SelectionType.Single,
items = orderTitleItems,
preSelectedItems = ImmutableList.of(selectedIndex),
onItemClick = {
selectedIndex = it
},
title = stringResource(UiR.string.friends_order_by_title),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
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(),
actions = {
IconButton(
onClick = {
showOrderDialog = true
}
) {
Icon(
painter = painterResource(UiR.drawable.round_filter_list_24),
contentDescription = null
)
}
}
)
PrimaryTabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier,
containerColor = Color.Transparent
) {
tabItems.forEachIndexed { index, item ->
Tab(
selected = index == selectedTabIndex,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
item.titleResId?.let { resId ->
Text(text = stringResource(id = resId))
}
}
)
}
}
}
}
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
FriendsScreen(
orderType = orderType,
padding = padding,
tabIndex = index,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { canScrollBackward = it },
onScrolledToTop = onScrolledToTop
)
}
}
}