From 6a69f2825653c10436d11b8a3d548df1c02fa9a5 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Tue, 17 Dec 2024 20:51:02 +0300 Subject: [PATCH] ability to use more animations (experimental); fix online friends loading; conversation avatar in messages history screen; test second tap on conversations item in bottom bar to scroll to top; etc --- .../meloda/fast/presentation/MainActivity.kt | 14 +++ .../meloda/fast/presentation/MainScreen.kt | 21 ++++- .../conversations/ConversationsRepository.kt | 6 +- .../ConversationsRepositoryImpl.kt | 41 ++++++++- .../dev/meloda/fast/datastore/AppSettings.kt | 7 ++ .../dev/meloda/fast/datastore/SettingsKeys.kt | 5 +- .../domain/LoadConversationsByIdUseCase.kt | 23 +++++ .../dev/meloda/fast/domain/di/DomainModule.kt | 3 + .../api/responses/ConversationsResponse.kt | 13 ++- .../conversations/ConversationsService.kt | 9 +- .../conversations/ConversationsUrls.kt | 1 + .../kotlin/dev/meloda/fast/auth/AuthGraph.kt | 1 + .../conversations/ConversationsViewModel.kt | 23 +++++ .../navigation/ConversationsNavigation.kt | 3 + .../presentation/ConversationsScreen.kt | 17 +++- .../meloda/fast/friends/FriendsViewModel.kt | 61 ++++++++++--- .../MessagesHistoryViewModel.kt | 16 +--- .../model/MessagesHistoryScreenState.kt | 2 + .../presentation/MessagesHistoryScreen.kt | 91 +++++++++++-------- .../presentation/MessagesList.kt | 10 +- .../meloda/fast/settings/SettingsViewModel.kt | 17 +++- 21 files changed, 299 insertions(+), 85 deletions(-) create mode 100644 core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt index 9c1fe083..aa576c6e 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt @@ -84,6 +84,7 @@ class MainActivity : AppCompatActivity() { ) createNotificationChannels() + requestNotificationPermissions() setContent { KoinContext { @@ -282,6 +283,15 @@ class MainActivity : AppCompatActivity() { } } + private fun requestNotificationPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQUEST_NOTIFICATION_PERMISSION_CODE + ) + } + } + private fun toggleLongPollService( enable: Boolean, inBackground: Boolean = AppSettings.Experimental.longPollInBackground @@ -321,4 +331,8 @@ class MainActivity : AppCompatActivity() { super.onDestroy() stopServices() } + + companion object { + private const val REQUEST_NOTIFICATION_PERMISSION_CODE = 1 + } } diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt index b2f3fa59..f44085b2 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt @@ -47,6 +47,8 @@ import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow @OptIn(ExperimentalHazeMaterialsApi::class) @Composable @@ -68,6 +70,14 @@ fun MainScreen( mutableIntStateOf(1) } + val sharedFlow = remember { + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + } + Scaffold( bottomBar = { NavigationBar( @@ -98,6 +108,8 @@ fun MainScreen( inclusive = true } } + } else { + sharedFlow.tryEmit(index) } }, icon = { @@ -156,7 +168,11 @@ fun MainScreen( enterTransition = { fadeIn(animationSpec = tween(200)) }, exitTransition = { fadeOut(animationSpec = tween(200)) } ) { - navigation(startDestination = navigationItems[selectedItemIndex].route) { + navigation( + startDestination = navigationItems[selectedItemIndex].route, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) } + ) { friendsScreen( onError = onError, navController = navController, @@ -165,8 +181,9 @@ fun MainScreen( conversationsScreen( onError = onError, onConversationItemClicked = onConversationItemClicked, + onPhotoClicked = onPhotoClicked, + scrollToTopFlow = sharedFlow, navController = navController, - onPhotoClicked = onPhotoClicked ) profileScreen( onError = onError, diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt index bb95625d..7a9c3dd1 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepository.kt @@ -1,8 +1,8 @@ package dev.meloda.fast.data.api.conversations +import com.slack.eithernet.ApiResult import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.network.RestApiErrorDomain -import com.slack.eithernet.ApiResult interface ConversationsRepository { @@ -11,6 +11,10 @@ interface ConversationsRepository { offset: Int? ): ApiResult, RestApiErrorDomain> + suspend fun getConversationsById( + peerIds: List + ): ApiResult, RestApiErrorDomain> + suspend fun storeConversations(conversations: List) suspend fun delete(peerId: Int): ApiResult suspend fun pin(peerId: Int): ApiResult diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt index bec92d4c..85d0be97 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/conversations/ConversationsRepositoryImpl.kt @@ -1,5 +1,6 @@ package dev.meloda.fast.data.api.conversations +import com.slack.eithernet.ApiResult import dev.meloda.fast.common.VkConstants import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkMemoryCache @@ -19,7 +20,6 @@ import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.service.conversations.ConversationsService -import com.slack.eithernet.ApiResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -79,6 +79,45 @@ class ConversationsRepositoryImpl( ) } + override suspend fun getConversationsById( + peerIds: List + ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { + val requestParams = mapOf( + "peer_ids" to peerIds.joinToString(separator = ","), + "extended" to "1", + "fields" to VkConstants.ALL_FIELDS + ) + + conversationsService.getConversationsById(requestParams).mapApiResult( + successMapper = { apiResponse -> + val response = apiResponse.requireResponse() + + val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain) + val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain) + val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain) + + val usersMap = VkUsersMap.forUsers(profilesList) + val groupsMap = VkGroupsMap.forGroups(groupsList) + + VkMemoryCache.appendUsers(profilesList) + VkMemoryCache.appendGroups(groupsList) + VkMemoryCache.appendContacts(contactsList) + + response.items.map { item -> + item.asDomain().let { conversation -> + conversation.copy( + user = usersMap.conversationUser(conversation), + group = groupsMap.conversationGroup(conversation) + ).also { VkMemoryCache[conversation.id] = it } + } + } + }, + errorMapper = { error -> + error?.toDomain() + } + ) + } + override suspend fun storeConversations(conversations: List) { conversationDao.insertAll(conversations.map(VkConversation::asEntity)) } diff --git a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt index 36a2b11b..386f2365 100644 --- a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt +++ b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt @@ -188,6 +188,13 @@ object AppSettings { SettingsKeys.DEFAULT_USE_BLUR ) set(value) = put(SettingsKeys.KEY_USE_BLUR, value) + + var moreAnimations: Boolean + get() = get( + SettingsKeys.KEY_MORE_ANIMATIONS, + SettingsKeys.DEFAULT_MORE_ANIMATIONS + ) + set(value) = put(SettingsKeys.KEY_MORE_ANIMATIONS, value) } object Debug { diff --git a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt index 3634c050..4d869d65 100644 --- a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt +++ b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/SettingsKeys.kt @@ -42,15 +42,16 @@ object SettingsKeys { const val KEY_DEBUG_PERFORM_CRASH = "debug_perform_crash" const val KEY_DEBUG_SHOW_CRASH_ALERT = "debug_show_crash_alert" const val KEY_DEBUG_HIDE_DEBUG_LIST = "debug_hide_debug_list" - const val KEY_ENABLE_ANIMATIONS_IN_MESSAGES = "debug_enable_animations_in_messages" const val KEY_ENABLE_HAPTIC = "enable_haptic" const val DEFAULT_ENABLE_HAPTIC = true const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level" const val DEFAULT_NETWORK_LOG_LEVEL = 0 const val KEY_USE_SYSTEM_FONT = "use_system_font" const val DEFAULT_USE_SYSTEM_FONT = false + const val KEY_MORE_ANIMATIONS = "more_animations" + const val DEFAULT_MORE_ANIMATIONS = false + const val KEY_SHOW_DEBUG_CATEGORY = "show_debug_category" - const val ID_DMITRY = 37610580 } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt new file mode 100644 index 00000000..309430ab --- /dev/null +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LoadConversationsByIdUseCase.kt @@ -0,0 +1,23 @@ +package dev.meloda.fast.domain + +import dev.meloda.fast.data.State +import dev.meloda.fast.data.api.conversations.ConversationsRepository +import dev.meloda.fast.data.mapToState +import dev.meloda.fast.model.api.domain.VkConversation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class LoadConversationsByIdUseCase( + private val conversationsRepository: ConversationsRepository +) { + + operator fun invoke(peerIds: List): Flow>> = flow { + emit(State.Loading) + + val newState = conversationsRepository + .getConversationsById(peerIds = peerIds) + .mapToState() + + emit(newState) + } +} diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/di/DomainModule.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/di/DomainModule.kt index 9fccd341..7a8579a7 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/di/DomainModule.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/di/DomainModule.kt @@ -6,6 +6,7 @@ import dev.meloda.fast.domain.AccountUseCaseImpl import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.GetLocalUserByIdUseCase import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase +import dev.meloda.fast.domain.LoadConversationsByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.domain.StoreUsersUseCase @@ -24,4 +25,6 @@ val domainModule = module { singleOf(::AccountUseCaseImpl) bind AccountUseCase::class singleOf(::GetCurrentAccountUseCase) + + singleOf(::LoadConversationsByIdUseCase) } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt index 1fb02ea4..927101a5 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/ConversationsResponse.kt @@ -1,12 +1,12 @@ package dev.meloda.fast.model.api.responses +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkConversationData import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkUserData -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ConversationsGetResponse( @@ -18,6 +18,15 @@ data class ConversationsGetResponse( @Json(name = "contacts") val contacts: List? ) +@JsonClass(generateAdapter = true) +data class ConversationsGetByIdResponse( + @Json(name = "count") val count: Int, + @Json(name = "items") val items: List, + @Json(name = "profiles") val profiles: List?, + @Json(name = "groups") val groups: List?, + @Json(name = "contacts") val contacts: List? +) + @JsonClass(generateAdapter = true) data class ConversationsResponseItem( @Json(name = "conversation") val conversation: VkConversationData, diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt index b03277ba..e891472b 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsService.kt @@ -1,10 +1,11 @@ package dev.meloda.fast.network.service.conversations +import com.slack.eithernet.ApiResult import dev.meloda.fast.model.api.responses.ConversationsDeleteResponse +import dev.meloda.fast.model.api.responses.ConversationsGetByIdResponse import dev.meloda.fast.model.api.responses.ConversationsGetResponse import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.RestApiError -import com.slack.eithernet.ApiResult import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.POST @@ -17,6 +18,12 @@ interface ConversationsService { @FieldMap params: Map ): ApiResult, RestApiError> + @FormUrlEncoded + @POST(ConversationsUrls.GET_BY_ID) + suspend fun getConversationsById( + @FieldMap params: Map + ): ApiResult, RestApiError> + @FormUrlEncoded @POST(ConversationsUrls.DELETE) suspend fun delete( diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt index 56b9fc0e..ba130cd3 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/conversations/ConversationsUrls.kt @@ -5,6 +5,7 @@ import dev.meloda.fast.common.AppConstants object ConversationsUrls { const val GET = "${AppConstants.URL_API}/messages.getConversations" + const val GET_BY_ID = "${AppConstants.URL_API}/messages.getConversationsById" const val DELETE = "${AppConstants.URL_API}/messages.deleteConversation" const val PIN = "${AppConstants.URL_API}/messages.pinConversation" const val UNPIN = "${AppConstants.URL_API}/messages.unpinConversation" diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt index 308c3bf1..d1aa7d82 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt @@ -87,6 +87,7 @@ fun NavGraphBuilder.authNavGraph( } } +// TODO: 17.12.2024, Danil Nikolaev: check clearing backstack from main screen fun NavController.navigateToAuth(clearBackStack: Boolean = false) { val navController = this diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt index f87c309a..cb575634 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt @@ -26,7 +26,9 @@ import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.ui.util.ImmutableList +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException interface ConversationsViewModel { @@ -43,6 +46,7 @@ interface ConversationsViewModel { val imagesToPreload: StateFlow> val currentOffset: StateFlow val canPaginate: StateFlow + val scrollToTop: StateFlow fun onPaginationConditionsMet() @@ -63,6 +67,10 @@ interface ConversationsViewModel { fun setScrollIndex(index: Int) fun setScrollOffset(offset: Int) + + + fun setScrollToTopFlow(scrollToTopFlow: Flow) + fun onScrolledToTop() } class ConversationsViewModelImpl( @@ -78,6 +86,7 @@ class ConversationsViewModelImpl( override val imagesToPreload = MutableStateFlow>(emptyList()) override val currentOffset = MutableStateFlow(0) override val canPaginate = MutableStateFlow(false) + override val scrollToTop = MutableStateFlow(false) override fun onPaginationConditionsMet() { currentOffset.update { screenState.value.conversations.size } @@ -217,6 +226,20 @@ class ConversationsViewModelImpl( screenState.setValue { old -> old.copy(scrollOffset = offset) } } + override fun setScrollToTopFlow(scrollToTopFlow: Flow) { + 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) { screenState.setValue { old -> old.copy( diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt index 66805ed8..ccc91b6c 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt @@ -8,6 +8,7 @@ import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.conversations.presentation.ConversationsRoute import dev.meloda.fast.model.BaseError import dev.meloda.fast.ui.extensions.sharedViewModel +import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable @Serializable @@ -17,11 +18,13 @@ fun NavGraphBuilder.conversationsScreen( onError: (BaseError) -> Unit, onConversationItemClicked: (id: Int) -> Unit, onPhotoClicked: (url: String) -> Unit, + scrollToTopFlow: Flow, navController: NavController, ) { composable { val viewModel: ConversationsViewModel = it.sharedViewModel(navController = navController) + viewModel.setScrollToTopFlow(scrollToTopFlow) ConversationsRoute( onError = onError, diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt index a81086c5..dbf270da 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt @@ -101,6 +101,7 @@ fun ConversationsRoute( val screenState by viewModel.screenState.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() + val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle() LaunchedEffect(imagesToPreload) { @@ -129,7 +130,9 @@ fun ConversationsRoute( onRefresh = viewModel::onRefresh, onConversationPhotoClicked = onConversationPhotoClicked, setScrollIndex = viewModel::setScrollIndex, - setScrollOffset = viewModel::setScrollOffset + setScrollOffset = viewModel::setScrollOffset, + isNeedToScrollToTop = isNeedToScrollToTop, + onScrolledToTop = viewModel::onScrolledToTop ) HandleDialogs( @@ -156,7 +159,9 @@ fun ConversationsScreen( onRefresh: () -> Unit = {}, onConversationPhotoClicked: (url: String) -> Unit = {}, setScrollIndex: (Int) -> Unit = {}, - setScrollOffset: (Int) -> Unit = {} + setScrollOffset: (Int) -> Unit = {}, + isNeedToScrollToTop: Boolean = false, + onScrolledToTop: () -> Unit = {} ) { val view = LocalView.current val currentTheme = LocalThemeConfig.current @@ -170,6 +175,14 @@ fun ConversationsScreen( initialFirstVisibleItemScrollOffset = screenState.scrollOffset ) + LaunchedEffect(isNeedToScrollToTop) { + if (isNeedToScrollToTop) { + listState.scrollToItem(0) + onScrolledToTop() + + } + } + LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } .debounce(500L) diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt index fc9db494..c01de124 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt @@ -8,6 +8,7 @@ import dev.meloda.fast.data.State import dev.meloda.fast.data.processState import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.domain.FriendsUseCase +import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.util.asPresentation import dev.meloda.fast.model.BaseError @@ -42,7 +43,8 @@ interface FriendsViewModel { class FriendsViewModelImpl( private val friendsUseCase: FriendsUseCase, - private val userSettings: UserSettings + private val userSettings: UserSettings, + private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase ) : ViewModel(), FriendsViewModel { override val screenState = MutableStateFlow(FriendsScreenState.EMPTY) @@ -94,6 +96,49 @@ class FriendsViewModelImpl( } private fun loadFriends(offset: Int = currentOffset.value) { + friendsUseCase.getOnlineFriends(null, null) + .listenValue(viewModelScope) { state -> + state.processState( + error = { error -> + if (error is State.Error.ApiError) { + when (error.errorCode) { + VkErrorCode.USER_AUTHORIZATION_FAILED -> { + baseError.setValue { BaseError.SessionExpired } + } + + else -> Unit + } + } + }, + success = { userIds -> + loadUsersByIdsUseCase(userIds = userIds) + .listenValue(viewModelScope) { state -> + state.processState( + error = { error -> + if (error is State.Error.ApiError) { + when (error.errorCode) { + VkErrorCode.USER_AUTHORIZATION_FAILED -> { + baseError.setValue { BaseError.SessionExpired } + } + + else -> Unit + } + } + }, + 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( @@ -125,10 +170,6 @@ class FriendsViewModelImpl( it.asPresentation(userSettings.useContactNames.value) } - val loadedOnlineFriends = loadedFriends.filter { - it.onlineStatus.isOnline() - } - val newState = screenState.value.copy( isPaginationExhausted = paginationExhausted ) @@ -136,18 +177,12 @@ class FriendsViewModelImpl( if (offset == 0) { friends.emit(response) screenState.setValue { - newState.copy( - friends = loadedFriends, - onlineFriends = loadedOnlineFriends - ) + newState.copy(friends = loadedFriends) } } else { friends.emit(friends.value.plus(response)) screenState.setValue { - newState.copy( - friends = newState.friends.plus(loadedFriends), - onlineFriends = newState.onlineFriends.plus(loadedOnlineFriends) - ) + newState.copy(friends = newState.friends.plus(loadedFriends)) } } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index f349cc65..c0f542d5 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -1,10 +1,8 @@ package dev.meloda.fast.messageshistory -import android.content.SharedPreferences import android.util.Log import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import androidx.core.content.edit import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -19,9 +17,9 @@ import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.processState import dev.meloda.fast.datastore.AppSettings -import dev.meloda.fast.datastore.SettingsKeys import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.domain.ConversationsUseCase +import dev.meloda.fast.domain.LoadConversationsByIdUseCase import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.messageshistory.model.ActionMode @@ -60,15 +58,14 @@ interface MessagesHistoryViewModel { fun onActionButtonClicked() fun onPaginationConditionsMet() - fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean) } class MessagesHistoryViewModelImpl( private val messagesUseCase: MessagesUseCase, private val conversationsUseCase: ConversationsUseCase, - private val preferences: SharedPreferences, private val resourceProvider: ResourceProvider, private val userSettings: UserSettings, + private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase, updatesParser: LongPollUpdatesParser, savedStateHandle: SavedStateHandle ) : MessagesHistoryViewModel, ViewModel() { @@ -159,15 +156,6 @@ class MessagesHistoryViewModelImpl( loadMessagesHistory() } - override fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean) { - preferences.edit { - putBoolean( - SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES, - enableAnimations - ) - } - } - private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { val message = event.message diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt index 349f4301..a8312f0a 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt @@ -18,6 +18,7 @@ data class MessagesHistoryScreenState( val isPaginating: Boolean, val isPaginationExhausted: Boolean, val actionMode: ActionMode, + val chatImageUrl: String? ) { companion object { @@ -33,6 +34,7 @@ data class MessagesHistoryScreenState( isPaginating = false, isPaginationExhausted = false, actionMode = ActionMode.Record, + chatImageUrl = null ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index 217d9705..9f2d2fe1 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -1,10 +1,12 @@ package dev.meloda.fast.messageshistory.presentation import android.content.SharedPreferences +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,9 +25,11 @@ import androidx.compose.foundation.layout.imeNestedScroll import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack @@ -59,6 +63,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -71,12 +76,12 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.core.view.HapticFeedbackConstantsCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.meloda.fast.datastore.AppSettings -import dev.meloda.fast.datastore.SettingsKeys import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.messageshistory.MessagesHistoryViewModel import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl @@ -88,6 +93,7 @@ import dev.meloda.fast.model.BaseError import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList +import dev.meloda.fast.ui.util.getImage import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @@ -115,7 +121,6 @@ fun MessagesHistoryRoute( onBack = onBack, onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked, onRefreshDropdownItemClicked = viewModel::onRefresh, - onToggleAnimationsDropdownItemClicked = viewModel::onToggleAnimationsDropdownItemClicked, onPaginationConditionsMet = viewModel::onPaginationConditionsMet, onMessageInputChanged = viewModel::onMessageInputChanged, onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked, @@ -174,15 +179,6 @@ fun MessagesHistoryScreen( val hazeState = remember { HazeState() } - var animationsEnabled by remember { - mutableStateOf( - preferences.getBoolean( - SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES, - false - ) - ) - } - val toolbarColorAlpha by animateFloatAsState( targetValue = if (!listState.canScrollForward) 1f else 0f, label = "toolbarColorAlpha", @@ -212,14 +208,42 @@ fun MessagesHistoryScreen( ) .fillMaxWidth(), title = { - Text( - text = - if (screenState.isLoading) stringResource(id = UiR.string.title_loading) - else screenState.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.headlineSmall - ) + Row( + modifier = Modifier + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + val avatar = screenState.avatar.getImage() + if (avatar is Painter) { + Image( + painter = avatar, + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + ) + } else { + AsyncImage( + model = screenState.avatar.getImage(), + contentDescription = "Profile Image", + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut), + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = + if (screenState.isLoading) stringResource(id = UiR.string.title_loading) + else screenState.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineSmall + ) + } }, navigationIcon = { IconButton(onClick = onBack) { @@ -282,31 +306,19 @@ fun MessagesHistoryScreen( ) } ) - - if (preferences.getBoolean( - SettingsKeys.KEY_SHOW_DEBUG_CATEGORY, - false - ) - ) { - HorizontalDivider() - - DropdownMenuItem( - text = { - Text(text = if (animationsEnabled) "Disable animations" else "Enable animations") - }, - onClick = { - dropDownMenuExpanded = false - animationsEnabled = !animationsEnabled - onToggleAnimationsDropdownItemClicked(animationsEnabled) - } - ) - } } } ) - if (screenState.isLoading && screenState.messages.isNotEmpty()) { + + val showHorizontalProgressBar by remember(screenState) { + derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() } + } + if (showHorizontalProgressBar) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } + AnimatedVisibility(!showHorizontalProgressBar) { + HorizontalDivider() + } } } ) { padding -> @@ -322,7 +334,6 @@ fun MessagesHistoryScreen( listState = listState, immutableMessages = ImmutableList.copyOf(screenState.messages), isPaginating = screenState.isPaginating, - enableAnimations = animationsEnabled, messageBarHeight = messageBarHeight, onRequestScrollToCmId = { cmId -> coroutineScope.launch { diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index e2dd3426..b329eeee 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -13,12 +13,14 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.haze +import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList @@ -30,11 +32,15 @@ fun MessagesList( listState: LazyListState, immutableMessages: ImmutableList, isPaginating: Boolean, - enableAnimations: Boolean, messageBarHeight: Dp, onRequestScrollToCmId: (cmId: Int) -> Unit = {} ) { - val messages = immutableMessages.toList() + val enableAnimations = remember { + AppSettings.Experimental.moreAnimations + } + val messages = remember(immutableMessages) { + immutableMessages.toList() + } val currentTheme = LocalThemeConfig.current LazyColumn( diff --git a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt index f529652d..00f3028b 100644 --- a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/SettingsViewModel.kt @@ -387,16 +387,22 @@ class SettingsViewModelImpl( title = UiText.Resource(UiR.string.settings_features_long_poll_in_background_title), text = UiText.Resource(UiR.string.settings_features_long_poll_in_background_summary) ) + val experimentalShowTimeInActionMessages = SettingsItem.Switch( + key = SettingsKeys.KEY_SHOW_TIME_IN_ACTION_MESSAGES, + defaultValue = SettingsKeys.DEFAULT_SHOW_TIME_IN_ACTION_MESSAGES, + title = UiText.Simple("Show time in action messages") + ) val experimentalUseBlur = SettingsItem.Switch( key = SettingsKeys.KEY_USE_BLUR, defaultValue = SettingsKeys.DEFAULT_USE_BLUR, title = UiText.Simple("Use blur"), text = UiText.Simple("Adds blur wherever possible\nWorks on android 12 and newer"), ) - val experimentalShowTimeInActionMessages = SettingsItem.Switch( - key = SettingsKeys.KEY_SHOW_TIME_IN_ACTION_MESSAGES, - defaultValue = SettingsKeys.DEFAULT_SHOW_TIME_IN_ACTION_MESSAGES, - title = UiText.Simple("Show time in action messages") + val enableAnimations = SettingsItem.Switch( + key = SettingsKeys.KEY_MORE_ANIMATIONS, + defaultValue = SettingsKeys.DEFAULT_MORE_ANIMATIONS, + title = UiText.Simple("More animations"), + text = UiText.Simple("Use animations wherever possible") ) val debugTitle = SettingsItem.Title( @@ -473,7 +479,8 @@ class SettingsViewModelImpl( experimentalTitle, experimentalLongPollBackground, experimentalShowTimeInActionMessages, - experimentalUseBlur + experimentalUseBlur, + enableAnimations ) val debugList = mutableListOf>() listOf(