Refactor: Enhance conversations and friends features

-   In `ConversationsScreen`, removed `isNeedToScrollToTop` and `onScrolledToTop`, and refactored toolbar container color logic. Added `NoItemsView` for empty conversation lists.
-   In `MainGraph`, added `onMessageClicked` for navigation to message history.
-   In `ApiEvent`, introduced `parseOrNull` for handling unknown event types.
-   In `ConversationsViewModel`, removed `scrollToTop` logic and refactored error handling.
-   In `FriendsViewModel`, refactored error handling and introduced `onErrorConsumed` and `handleError`.
-   In `FriendItem`, added an icon button to initiate sending a message to a friend.
-   In `strings.xml`, added or updated strings for session expiration, log out, refreshing, and empty friend lists.
-   In `RootScreen`, added `onMessageClicked` for navigating to messages.
-   In `FriendsList`, added `onMessageClicked` for handling message clicks.
-   In `MainScreen`, removed unused `MutableSharedFlow`.
-   In `FriendsScreen`, added support for showing errors, added `onMessageClicked`, and replaced `hazeChild` with `hazeEffect` and `hazeSource`.
-   In `FriendsNavigation`, added `onMessageClicked` for handling message clicks.
-   In `ConversationsNavigation`, removed the unused `scrollToTopFlow` parameter.
-   In `ErrorView`, added text alignment.
-   In `NoItemsView`, added support for a button and custom text.
-   In `LongPollUpdatesParser`, replaced try-catch with `parseOrNull`.
This commit is contained in:
2025-03-21 12:43:22 +03:00
parent 1a78a51017
commit 36a119ffa9
17 changed files with 173 additions and 141 deletions
@@ -24,6 +24,7 @@ fun NavGraphBuilder.mainScreen(
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onConversationClicked: (conversationId: Int) -> Unit, onConversationClicked: (conversationId: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
@@ -54,6 +55,7 @@ fun NavGraphBuilder.mainScreen(
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onConversationItemClicked = onConversationClicked, onConversationItemClicked = onConversationClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -47,8 +47,6 @@ import dev.meloda.fast.ui.theme.LocalBottomPadding
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.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@OptIn(ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
@@ -58,6 +56,7 @@ fun MainScreen(
onSettingsButtonClicked: () -> Unit = {}, onSettingsButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {},
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -70,14 +69,6 @@ fun MainScreen(
mutableIntStateOf(1) mutableIntStateOf(1)
} }
val sharedFlow = remember {
MutableSharedFlow<Int>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
Scaffold( Scaffold(
bottomBar = { bottomBar = {
NavigationBar( NavigationBar(
@@ -108,8 +99,6 @@ fun MainScreen(
inclusive = true inclusive = true
} }
} }
} else {
sharedFlow.tryEmit(index)
} }
}, },
icon = { icon = {
@@ -176,13 +165,13 @@ fun MainScreen(
friendsScreen( friendsScreen(
onError = onError, onError = onError,
navController = navController, navController = navController,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
) )
conversationsScreen( conversationsScreen(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
scrollToTopFlow = sharedFlow,
navController = navController, navController = navController,
) )
profileScreen( profileScreen(
@@ -124,6 +124,7 @@ fun RootScreen(
onSettingsButtonClicked = navController::navigateToSettings, onSettingsButtonClicked = navController::navigateToSettings,
onConversationClicked = navController::navigateToMessagesHistory, onConversationClicked = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }, onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
onMessageClicked = navController::navigateToMessagesHistory,
viewModel = viewModel viewModel = viewModel
) )
@@ -41,15 +41,9 @@ class LongPollUpdatesParser(
fun parseNextUpdate(event: List<Any>) { fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt() val eventId = event.first().asInt()
val eventType: ApiEvent = try { when (val eventType = ApiEvent.parseOrNull(eventId)) {
ApiEvent.parse(eventId) null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
} catch (e: Exception) {
e.printStackTrace()
Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
return
}
when (eventType) {
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event) ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
@@ -18,5 +18,6 @@ enum class ApiEvent(val value: Int) {
companion object { companion object {
fun parse(value: Int) = entries.first { it.value == value } fun parse(value: Int) = entries.first { it.value == value }
fun parseOrNull(value: Int) = entries.firstOrNull { it.value == value }
} }
} }
@@ -12,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -31,7 +32,8 @@ fun ErrorView(
) { ) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
) )
buttonText?.let { buttonText?.let {
@@ -1,29 +1,51 @@
package dev.meloda.fast.ui.components package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@Composable @Composable
fun NoItemsView( fun NoItemsView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
customText: String? = null customText: String? = null,
buttonText: String? = null,
onButtonClick: (() -> Unit)? = null,
) { ) {
Box( Column(
modifier = modifier.fillMaxSize(), modifier = modifier
contentAlignment = Alignment.Center .fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = customText ?: stringResource(id = R.string.no_items), text = customText ?: stringResource(R.string.no_items),
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
) )
buttonText?.let {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { onButtonClick?.invoke() }
) {
Text(text = buttonText)
}
}
} }
} }
@@ -31,6 +53,7 @@ fun NoItemsView(
@Composable @Composable
private fun NoItemsViewPreview() { private fun NoItemsViewPreview() {
NoItemsView( NoItemsView(
customText = "Nothing here..." customText = "Nothing here...",
buttonText = "Refresh"
) )
} }
+4 -1
View File
@@ -128,7 +128,7 @@
<string name="post_type_community">Запись сообщества</string> <string name="post_type_community">Запись сообщества</string>
<string name="post_type_user">Запись пользователя</string> <string name="post_type_user">Запись пользователя</string>
<string name="post_type_unknown">Запись на стене</string> <string name="post_type_unknown">Запись на стене</string>
<string name="log_out">Выйти</string> <string name="action_log_out">Выйти</string>
<string name="confirm">Подтверждение</string> <string name="confirm">Подтверждение</string>
<string name="message_attachment_story_your_story">Ваша история</string> <string name="message_attachment_story_your_story">Ваша история</string>
<string name="settings_dynamic_colors">Динамические цвета</string> <string name="settings_dynamic_colors">Динамические цвета</string>
@@ -212,4 +212,7 @@
<string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string> <string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string>
<string name="validation_exit_warning">Вы уверены? Процесс ввода кода-подтверждения будет отменён</string> <string name="validation_exit_warning">Вы уверены? Процесс ввода кода-подтверждения будет отменён</string>
<string name="action_authorize">Авторизоваться</string> <string name="action_authorize">Авторизоваться</string>
<string name="no_online_friends">Никого в сети</string>
<string name="try_again">Попробовать ещё раз</string>
<string name="session_expired">Срок действия сессии истёк</string>
</resources> </resources>
+4 -2
View File
@@ -119,7 +119,7 @@
<string name="post_type_community">Community post</string> <string name="post_type_community">Community post</string>
<string name="post_type_user">User post</string> <string name="post_type_user">User post</string>
<string name="post_type_unknown">Post</string> <string name="post_type_unknown">Post</string>
<string name="log_out">Log out</string> <string name="action_log_out">Log out</string>
<string name="confirm">Confirmation</string> <string name="confirm">Confirmation</string>
<string name="sign_out_confirm">Signing out will delete all data related to this account from this device. Continue?</string> <string name="sign_out_confirm">Signing out will delete all data related to this account from this device. Continue?</string>
<string name="yes">Yes</string> <string name="yes">Yes</string>
@@ -276,6 +276,8 @@
<string name="warning_confirmation">Confirmation</string> <string name="warning_confirmation">Confirmation</string>
<string name="captcha_exit_warning">Are you sure? Captcha process will be cancelled</string> <string name="captcha_exit_warning">Are you sure? Captcha process will be cancelled</string>
<string name="validation_exit_warning">Are you sure? Validation process will be cancelled</string> <string name="validation_exit_warning">Are you sure? Validation process will be cancelled</string>
<string name="settings_general_enable_pull_to_refresh_title">Enable pull to refresh</string>
<string name="action_authorize">Authorize</string> <string name="action_authorize">Authorize</string>
<string name="no_online_friends">No one is online</string>
<string name="try_again">Try again</string>
<string name="session_expired">Session expired</string>
</resources> </resources>
@@ -30,9 +30,7 @@ import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -40,7 +38,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
interface ConversationsViewModel { interface ConversationsViewModel {
@@ -49,7 +46,6 @@ interface ConversationsViewModel {
val baseError: StateFlow<BaseError?> val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int> val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
val scrollToTop: StateFlow<Boolean>
fun onPaginationConditionsMet() fun onPaginationConditionsMet()
@@ -70,10 +66,6 @@ interface ConversationsViewModel {
fun setScrollIndex(index: Int) fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int) fun setScrollOffset(offset: Int)
fun setScrollToTopFlow(scrollToTopFlow: Flow<Int>)
fun onScrolledToTop()
} }
class ConversationsViewModelImpl( class ConversationsViewModelImpl(
@@ -91,7 +83,6 @@ class ConversationsViewModelImpl(
override val baseError = MutableStateFlow<BaseError?>(null) override val baseError = MutableStateFlow<BaseError?>(null)
override val currentOffset = MutableStateFlow(0) override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) override val canPaginate = MutableStateFlow(false)
override val scrollToTop = MutableStateFlow(false)
// TODO: 22-Dec-24, Danil Nikolaev: rewrite // TODO: 22-Dec-24, Danil Nikolaev: rewrite
private val useContactNames = { private val useContactNames = {
@@ -134,7 +125,7 @@ class ConversationsViewModelImpl(
} }
override fun onRefresh() { override fun onRefresh() {
baseError.setValue { null } onErrorConsumed()
loadConversations(offset = 0) loadConversations(offset = 0)
} }
@@ -237,20 +228,6 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> old.copy(scrollOffset = offset) } screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
override fun setScrollToTopFlow(scrollToTopFlow: Flow<Int>) {
scrollToTopFlow.listenValue(viewModelScope) { index ->
if (index == 1) {
scrollToTop.emit(true)
}
}
}
override fun onScrolledToTop() {
viewModelScope.launch(Dispatchers.Main) {
scrollToTop.emit(false)
}
}
private fun hideOptions(conversationId: Int) { private fun hideOptions(conversationId: Int) {
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
@@ -8,7 +8,6 @@ import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.presentation.ConversationsRoute import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.extensions.sharedViewModel import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -18,13 +17,11 @@ fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit, onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
scrollToTopFlow: Flow<Int>,
navController: NavController, navController: NavController,
) { ) {
composable<Conversations> { composable<Conversations> {
val viewModel: ConversationsViewModel = val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController) it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
viewModel.setScrollToTopFlow(scrollToTopFlow)
ConversationsRoute( ConversationsRoute(
onError = onError, onError = onError,
@@ -77,6 +77,7 @@ import dev.meloda.fast.model.BaseError
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.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
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
@@ -96,7 +97,6 @@ fun ConversationsRoute(
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle()
ConversationsScreen( ConversationsScreen(
screenState = screenState, screenState = screenState,
@@ -114,9 +114,7 @@ fun ConversationsRoute(
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked, onConversationPhotoClicked = onConversationPhotoClicked,
setScrollIndex = viewModel::setScrollIndex, setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset, setScrollOffset = viewModel::setScrollOffset
isNeedToScrollToTop = isNeedToScrollToTop,
onScrolledToTop = viewModel::onScrolledToTop
) )
HandleDialogs( HandleDialogs(
@@ -143,9 +141,7 @@ fun ConversationsScreen(
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {}, onConversationPhotoClicked: (url: String) -> Unit = {},
setScrollIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}, setScrollOffset: (Int) -> Unit = {}
isNeedToScrollToTop: Boolean = false,
onScrolledToTop: () -> Unit = {}
) { ) {
val view = LocalView.current val view = LocalView.current
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -159,14 +155,6 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset initialFirstVisibleItemScrollOffset = screenState.scrollOffset
) )
LaunchedEffect(isNeedToScrollToTop) {
if (isNeedToScrollToTop) {
listState.scrollToItem(0)
onScrolledToTop()
}
}
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L) .debounce(500L)
@@ -207,10 +195,10 @@ fun ConversationsScreen(
val toolbarContainerColor by animateColorAsState( val toolbarContainerColor by animateColorAsState(
targetValue = targetValue =
if (currentTheme.enableBlur || !listState.canScrollBackward) if (currentTheme.enableBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
else else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha", label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50) animationSpec = tween(durationMillis = 50)
) )
@@ -343,8 +331,8 @@ fun ConversationsScreen(
when (baseError) { when (baseError) {
is BaseError.SessionExpired -> { is BaseError.SessionExpired -> {
ErrorView( ErrorView(
text = "Session expired", text = stringResource(UiR.string.session_expired),
buttonText = "Log out", buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked onButtonClick = onSessionExpiredLogOutButtonClicked
) )
} }
@@ -352,7 +340,7 @@ fun ConversationsScreen(
is BaseError.SimpleError -> { is BaseError.SimpleError -> {
ErrorView( ErrorView(
text = baseError.message, text = baseError.message,
buttonText = "Try again", buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh onButtonClick = onRefresh
) )
} }
@@ -398,6 +386,13 @@ fun ConversationsScreen(
padding = padding, padding = padding,
onPhotoClicked = onConversationPhotoClicked onPhotoClicked = onConversationPhotoClicked
) )
if (screenState.conversations.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
} }
} }
} }
@@ -68,6 +68,7 @@ class FriendsViewModelImpl(
} }
override fun onRefresh() { override fun onRefresh() {
onErrorConsumed()
loadFriends(offset = 0) loadFriends(offset = 0)
} }
@@ -99,32 +100,12 @@ class FriendsViewModelImpl(
friendsUseCase.getOnlineFriends(null, null) friendsUseCase.getOnlineFriends(null, null)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = ::handleError,
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
success = { userIds -> success = { userIds ->
loadUsersByIdsUseCase(userIds = userIds) loadUsersByIdsUseCase(userIds = userIds)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = ::handleError,
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
success = { onlineFriends -> success = { onlineFriends ->
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
@@ -142,17 +123,7 @@ class FriendsViewModelImpl(
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset) friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = ::handleError,
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
success = { response -> success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
@@ -197,6 +168,40 @@ 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 -> {
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
}
}
private fun updateFriendsNames(useContactNames: Boolean) { private fun updateFriendsNames(useContactNames: Boolean) {
val friends = friends.value val friends = friends.value
if (friends.isEmpty()) return if (friends.isEmpty()) return
@@ -16,7 +16,8 @@ object Friends
fun NavGraphBuilder.friendsScreen( fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
navController: NavController, navController: NavController,
onPhotoClicked: (url: String) -> Unit onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
) { ) {
composable<Friends> { composable<Friends> {
val viewModel: FriendsViewModel = val viewModel: FriendsViewModel =
@@ -25,7 +26,8 @@ fun NavGraphBuilder.friendsScreen(
FriendsRoute( FriendsRoute(
onError = onError, onError = onError,
viewModel = viewModel, viewModel = viewModel,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
) )
} }
} }
@@ -12,6 +12,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MailOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -31,7 +35,8 @@ fun FriendItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
friend: UiFriend, friend: UiFriend,
maxLines: Int, maxLines: Int,
onPhotoClicked: (url: String) -> Unit onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -92,9 +97,24 @@ fun FriendItem(
text = friend.title, text = friend.title,
minLines = 1, minLines = 1,
maxLines = maxLines, maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp) style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
modifier = Modifier.weight(1f)
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = {
onMessageClicked(friend.userId)
}
) {
Icon(
imageVector = Icons.Rounded.MailOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(16.dp))
} }
} }
@@ -37,6 +37,7 @@ fun FriendsList(
maxLines: Int, maxLines: Int,
padding: PaddingValues, padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
setCanScrollBackward: (Boolean) -> Unit setCanScrollBackward: (Boolean) -> Unit
) { ) {
LaunchedEffect(listState) { LaunchedEffect(listState) {
@@ -66,7 +67,8 @@ fun FriendsList(
FriendItem( FriendItem(
friend = friend, friend = friend,
maxLines = maxLines, maxLines = maxLines,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -48,8 +48,8 @@ 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.haze import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.friends.FriendsViewModel import dev.meloda.fast.friends.FriendsViewModel
@@ -72,6 +72,7 @@ import dev.meloda.fast.ui.R as UiR
fun FriendsRoute( fun FriendsRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>() viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>()
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -99,11 +100,12 @@ fun FriendsRoute(
onPaginationConditionsMet = viewModel::onPaginationConditionsMet, onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setSelectedTabIndex = viewModel::onTabSelected, setSelectedTabIndex = viewModel::onTabSelected,
setScrollIndex = viewModel::setScrollIndex, setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset, setScrollOffset = viewModel::setScrollOffset,
setScrollIndexOnline = viewModel::setScrollIndexOnline, setScrollIndexOnline = viewModel::setScrollIndexOnline,
setScrollOffsetOnline = viewModel::setScrollOffsetOnline, setScrollOffsetOnline = viewModel::setScrollOffsetOnline
) )
} }
@@ -120,11 +122,12 @@ fun FriendsScreen(
onPaginationConditionsMet: () -> Unit = {}, onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {},
setSelectedTabIndex: (Int) -> Unit = {}, setSelectedTabIndex: (Int) -> Unit = {},
setScrollIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}, setScrollOffset: (Int) -> Unit = {},
setScrollIndexOnline: (Int) -> Unit = {}, setScrollIndexOnline: (Int) -> Unit = {},
setScrollOffsetOnline: (Int) -> Unit = {}, setScrollOffsetOnline: (Int) -> Unit = {}
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -231,7 +234,7 @@ fun FriendsScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.hazeChild( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.thick() style = HazeMaterials.thick()
) )
@@ -281,12 +284,24 @@ fun FriendsScreen(
} }
) { padding -> ) { padding ->
when { when {
baseError is BaseError.SessionExpired -> { baseError != null -> {
ErrorView( when (baseError) {
text = "Session expired", is BaseError.SessionExpired -> {
buttonText = "Log out", ErrorView(
onButtonClick = onSessionExpiredLogOutButtonClicked 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() screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
@@ -333,15 +348,17 @@ fun FriendsScreen(
) )
} }
) { ) {
val friendsToDisplay = if (index == 0) { val friendsToDisplay = remember(index) {
screenState.friends if (index == 0) {
} else { screenState.friends
screenState.onlineFriends } else {
screenState.onlineFriends
}
} }
FriendsList( FriendsList(
modifier = if (currentTheme.enableBlur) { modifier = if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState) Modifier.hazeSource(state = hazeState)
} else { } else {
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
@@ -351,6 +368,7 @@ fun FriendsScreen(
maxLines = maxLines, maxLines = maxLines,
padding = padding, padding = padding,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { can -> setCanScrollBackward = { can ->
canScrollBackward = can canScrollBackward = can
} }
@@ -358,10 +376,9 @@ fun FriendsScreen(
if (friendsToDisplay.isEmpty()) { if (friendsToDisplay.isEmpty()) {
NoItemsView( NoItemsView(
modifier = Modifier customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null,
.padding(padding.calculateTopPadding()) buttonText = stringResource(UiR.string.action_refresh),
.padding(top = 16.dp), onButtonClick = onRefresh
customText = "No${if (index == 1) " online" else ""} friends :("
) )
} }
} }