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
This commit is contained in:
2024-12-17 20:51:02 +03:00
parent 85cda2065e
commit 6a69f28256
21 changed files with 299 additions and 85 deletions
@@ -84,6 +84,7 @@ class MainActivity : AppCompatActivity() {
) )
createNotificationChannels() createNotificationChannels()
requestNotificationPermissions()
setContent { setContent {
KoinContext { 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( private fun toggleLongPollService(
enable: Boolean, enable: Boolean,
inBackground: Boolean = AppSettings.Experimental.longPollInBackground inBackground: Boolean = AppSettings.Experimental.longPollInBackground
@@ -321,4 +331,8 @@ class MainActivity : AppCompatActivity() {
super.onDestroy() super.onDestroy()
stopServices() stopServices()
} }
companion object {
private const val REQUEST_NOTIFICATION_PERMISSION_CODE = 1
}
} }
@@ -47,6 +47,8 @@ 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
@@ -68,6 +70,14 @@ 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(
@@ -98,6 +108,8 @@ fun MainScreen(
inclusive = true inclusive = true
} }
} }
} else {
sharedFlow.tryEmit(index)
} }
}, },
icon = { icon = {
@@ -156,7 +168,11 @@ fun MainScreen(
enterTransition = { fadeIn(animationSpec = tween(200)) }, enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) } exitTransition = { fadeOut(animationSpec = tween(200)) }
) { ) {
navigation<MainGraph>(startDestination = navigationItems[selectedItemIndex].route) { navigation<MainGraph>(
startDestination = navigationItems[selectedItemIndex].route,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
friendsScreen( friendsScreen(
onError = onError, onError = onError,
navController = navController, navController = navController,
@@ -165,8 +181,9 @@ fun MainScreen(
conversationsScreen( conversationsScreen(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked,
scrollToTopFlow = sharedFlow,
navController = navController, navController = navController,
onPhotoClicked = onPhotoClicked
) )
profileScreen( profileScreen(
onError = onError, onError = onError,
@@ -1,8 +1,8 @@
package dev.meloda.fast.data.api.conversations package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface ConversationsRepository { interface ConversationsRepository {
@@ -11,6 +11,10 @@ interface ConversationsRepository {
offset: Int? offset: Int?
): ApiResult<List<VkConversation>, RestApiErrorDomain> ): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun getConversationsById(
peerIds: List<Int>
): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun storeConversations(conversations: List<VkConversation>) suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun delete(peerId: Int): ApiResult<Int, RestApiErrorDomain> suspend fun delete(peerId: Int): ApiResult<Int, RestApiErrorDomain>
suspend fun pin(peerId: Int): ApiResult<Int, RestApiErrorDomain> suspend fun pin(peerId: Int): ApiResult<Int, RestApiErrorDomain>
@@ -1,5 +1,6 @@
package dev.meloda.fast.data.api.conversations package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache 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.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.conversations.ConversationsService import dev.meloda.fast.network.service.conversations.ConversationsService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -79,6 +79,45 @@ class ConversationsRepositoryImpl(
) )
} }
override suspend fun getConversationsById(
peerIds: List<Int>
): ApiResult<List<VkConversation>, 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<VkConversation>) { override suspend fun storeConversations(conversations: List<VkConversation>) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity)) conversationDao.insertAll(conversations.map(VkConversation::asEntity))
} }
@@ -188,6 +188,13 @@ object AppSettings {
SettingsKeys.DEFAULT_USE_BLUR SettingsKeys.DEFAULT_USE_BLUR
) )
set(value) = put(SettingsKeys.KEY_USE_BLUR, value) 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 { object Debug {
@@ -42,15 +42,16 @@ object SettingsKeys {
const val KEY_DEBUG_PERFORM_CRASH = "debug_perform_crash" const val KEY_DEBUG_PERFORM_CRASH = "debug_perform_crash"
const val KEY_DEBUG_SHOW_CRASH_ALERT = "debug_show_crash_alert" 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_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 KEY_ENABLE_HAPTIC = "enable_haptic"
const val DEFAULT_ENABLE_HAPTIC = true const val DEFAULT_ENABLE_HAPTIC = true
const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level" const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level"
const val DEFAULT_NETWORK_LOG_LEVEL = 0 const val DEFAULT_NETWORK_LOG_LEVEL = 0
const val KEY_USE_SYSTEM_FONT = "use_system_font" const val KEY_USE_SYSTEM_FONT = "use_system_font"
const val DEFAULT_USE_SYSTEM_FONT = false 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 KEY_SHOW_DEBUG_CATEGORY = "show_debug_category"
const val ID_DMITRY = 37610580 const val ID_DMITRY = 37610580
} }
@@ -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<Int>): Flow<State<List<VkConversation>>> = flow {
emit(State.Loading)
val newState = conversationsRepository
.getConversationsById(peerIds = peerIds)
.mapToState()
emit(newState)
}
}
@@ -6,6 +6,7 @@ import dev.meloda.fast.domain.AccountUseCaseImpl
import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.StoreUsersUseCase import dev.meloda.fast.domain.StoreUsersUseCase
@@ -24,4 +25,6 @@ val domainModule = module {
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::GetCurrentAccountUseCase) singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConversationsByIdUseCase)
} }
@@ -1,12 +1,12 @@
package dev.meloda.fast.model.api.responses 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.VkContactData
import dev.meloda.fast.model.api.data.VkConversationData import dev.meloda.fast.model.api.data.VkConversationData
import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ConversationsGetResponse( data class ConversationsGetResponse(
@@ -18,6 +18,15 @@ data class ConversationsGetResponse(
@Json(name = "contacts") val contacts: List<VkContactData>? @Json(name = "contacts") val contacts: List<VkContactData>?
) )
@JsonClass(generateAdapter = true)
data class ConversationsGetByIdResponse(
@Json(name = "count") val count: Int,
@Json(name = "items") val items: List<VkConversationData>,
@Json(name = "profiles") val profiles: List<VkUserData>?,
@Json(name = "groups") val groups: List<VkGroupData>?,
@Json(name = "contacts") val contacts: List<VkContactData>?
)
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ConversationsResponseItem( data class ConversationsResponseItem(
@Json(name = "conversation") val conversation: VkConversationData, @Json(name = "conversation") val conversation: VkConversationData,
@@ -1,10 +1,11 @@
package dev.meloda.fast.network.service.conversations 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.ConversationsDeleteResponse
import dev.meloda.fast.model.api.responses.ConversationsGetByIdResponse
import dev.meloda.fast.model.api.responses.ConversationsGetResponse import dev.meloda.fast.model.api.responses.ConversationsGetResponse
import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError import dev.meloda.fast.network.RestApiError
import com.slack.eithernet.ApiResult
import retrofit2.http.FieldMap import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST import retrofit2.http.POST
@@ -17,6 +18,12 @@ interface ConversationsService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<ConversationsGetResponse>, RestApiError> ): ApiResult<ApiResponse<ConversationsGetResponse>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.GET_BY_ID)
suspend fun getConversationsById(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<ConversationsGetByIdResponse>, RestApiError>
@FormUrlEncoded @FormUrlEncoded
@POST(ConversationsUrls.DELETE) @POST(ConversationsUrls.DELETE)
suspend fun delete( suspend fun delete(
@@ -5,6 +5,7 @@ import dev.meloda.fast.common.AppConstants
object ConversationsUrls { object ConversationsUrls {
const val GET = "${AppConstants.URL_API}/messages.getConversations" 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 DELETE = "${AppConstants.URL_API}/messages.deleteConversation"
const val PIN = "${AppConstants.URL_API}/messages.pinConversation" const val PIN = "${AppConstants.URL_API}/messages.pinConversation"
const val UNPIN = "${AppConstants.URL_API}/messages.unpinConversation" const val UNPIN = "${AppConstants.URL_API}/messages.unpinConversation"
@@ -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) { fun NavController.navigateToAuth(clearBackStack: Boolean = false) {
val navController = this val navController = this
@@ -26,7 +26,9 @@ 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
@@ -34,6 +36,7 @@ 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 {
@@ -43,6 +46,7 @@ interface ConversationsViewModel {
val imagesToPreload: StateFlow<List<String>> val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int> val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
val scrollToTop: StateFlow<Boolean>
fun onPaginationConditionsMet() fun onPaginationConditionsMet()
@@ -63,6 +67,10 @@ 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(
@@ -78,6 +86,7 @@ class ConversationsViewModelImpl(
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList()) override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
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)
override fun onPaginationConditionsMet() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size } currentOffset.update { screenState.value.conversations.size }
@@ -217,6 +226,20 @@ 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,6 +8,7 @@ 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
@@ -17,11 +18,13 @@ 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,
@@ -101,6 +101,7 @@ 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()
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle() val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) { LaunchedEffect(imagesToPreload) {
@@ -129,7 +130,9 @@ 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(
@@ -156,7 +159,9 @@ 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
@@ -170,6 +175,14 @@ 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)
@@ -8,6 +8,7 @@ import dev.meloda.fast.data.State
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.util.asPresentation import dev.meloda.fast.friends.util.asPresentation
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
@@ -42,7 +43,8 @@ interface FriendsViewModel {
class FriendsViewModelImpl( class FriendsViewModelImpl(
private val friendsUseCase: FriendsUseCase, private val friendsUseCase: FriendsUseCase,
private val userSettings: UserSettings private val userSettings: UserSettings,
private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase
) : ViewModel(), FriendsViewModel { ) : ViewModel(), FriendsViewModel {
override val screenState = MutableStateFlow(FriendsScreenState.EMPTY) override val screenState = MutableStateFlow(FriendsScreenState.EMPTY)
@@ -94,6 +96,49 @@ class FriendsViewModelImpl(
} }
private fun loadFriends(offset: Int = currentOffset.value) { 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) friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
@@ -125,10 +170,6 @@ class FriendsViewModelImpl(
it.asPresentation(userSettings.useContactNames.value) it.asPresentation(userSettings.useContactNames.value)
} }
val loadedOnlineFriends = loadedFriends.filter {
it.onlineStatus.isOnline()
}
val newState = screenState.value.copy( val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted isPaginationExhausted = paginationExhausted
) )
@@ -136,18 +177,12 @@ class FriendsViewModelImpl(
if (offset == 0) { if (offset == 0) {
friends.emit(response) friends.emit(response)
screenState.setValue { screenState.setValue {
newState.copy( newState.copy(friends = loadedFriends)
friends = loadedFriends,
onlineFriends = loadedOnlineFriends
)
} }
} else { } else {
friends.emit(friends.value.plus(response)) friends.emit(friends.value.plus(response))
screenState.setValue { screenState.setValue {
newState.copy( newState.copy(friends = newState.friends.plus(loadedFriends))
friends = newState.friends.plus(loadedFriends),
onlineFriends = newState.onlineFriends.plus(loadedOnlineFriends)
)
} }
} }
} }
@@ -1,10 +1,8 @@
package dev.meloda.fast.messageshistory package dev.meloda.fast.messageshistory
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.content.edit
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.VkMemoryCache
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConversationsUseCase import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.ActionMode
@@ -60,15 +58,14 @@ interface MessagesHistoryViewModel {
fun onActionButtonClicked() fun onActionButtonClicked()
fun onPaginationConditionsMet() fun onPaginationConditionsMet()
fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean)
} }
class MessagesHistoryViewModelImpl( class MessagesHistoryViewModelImpl(
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val preferences: SharedPreferences,
private val resourceProvider: ResourceProvider, private val resourceProvider: ResourceProvider,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase,
updatesParser: LongPollUpdatesParser, updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() { ) : MessagesHistoryViewModel, ViewModel() {
@@ -159,15 +156,6 @@ class MessagesHistoryViewModelImpl(
loadMessagesHistory() loadMessagesHistory()
} }
override fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean) {
preferences.edit {
putBoolean(
SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES,
enableAnimations
)
}
}
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) {
val message = event.message val message = event.message
@@ -18,6 +18,7 @@ data class MessagesHistoryScreenState(
val isPaginating: Boolean, val isPaginating: Boolean,
val isPaginationExhausted: Boolean, val isPaginationExhausted: Boolean,
val actionMode: ActionMode, val actionMode: ActionMode,
val chatImageUrl: String?
) { ) {
companion object { companion object {
@@ -33,6 +34,7 @@ data class MessagesHistoryScreenState(
isPaginating = false, isPaginating = false,
isPaginationExhausted = false, isPaginationExhausted = false,
actionMode = ActionMode.Record, actionMode = ActionMode.Record,
chatImageUrl = null
) )
} }
} }
@@ -1,10 +1,12 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
@@ -71,12 +76,12 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeChild
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.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl 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.components.IconButton
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 dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -115,7 +121,6 @@ fun MessagesHistoryRoute(
onBack = onBack, onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked, onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefreshDropdownItemClicked = viewModel::onRefresh, onRefreshDropdownItemClicked = viewModel::onRefresh,
onToggleAnimationsDropdownItemClicked = viewModel::onToggleAnimationsDropdownItemClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet, onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onMessageInputChanged = viewModel::onMessageInputChanged, onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked, onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
@@ -174,15 +179,6 @@ fun MessagesHistoryScreen(
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
var animationsEnabled by remember {
mutableStateOf(
preferences.getBoolean(
SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES,
false
)
)
}
val toolbarColorAlpha by animateFloatAsState( val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollForward) 1f else 0f, targetValue = if (!listState.canScrollForward) 1f else 0f,
label = "toolbarColorAlpha", label = "toolbarColorAlpha",
@@ -212,6 +208,33 @@ fun MessagesHistoryScreen(
) )
.fillMaxWidth(), .fillMaxWidth(),
title = { title = {
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(
text = text =
if (screenState.isLoading) stringResource(id = UiR.string.title_loading) if (screenState.isLoading) stringResource(id = UiR.string.title_loading)
@@ -220,6 +243,7 @@ fun MessagesHistoryScreen(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
}
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
@@ -282,31 +306,19 @@ fun MessagesHistoryScreen(
) )
} }
) )
}
}
)
if (preferences.getBoolean( val showHorizontalProgressBar by remember(screenState) {
SettingsKeys.KEY_SHOW_DEBUG_CATEGORY, derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() }
false
)
) {
HorizontalDivider()
DropdownMenuItem(
text = {
Text(text = if (animationsEnabled) "Disable animations" else "Enable animations")
},
onClick = {
dropDownMenuExpanded = false
animationsEnabled = !animationsEnabled
onToggleAnimationsDropdownItemClicked(animationsEnabled)
} }
) if (showHorizontalProgressBar) {
}
}
}
)
if (screenState.isLoading && screenState.messages.isNotEmpty()) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} }
AnimatedVisibility(!showHorizontalProgressBar) {
HorizontalDivider()
}
} }
} }
) { padding -> ) { padding ->
@@ -322,7 +334,6 @@ fun MessagesHistoryScreen(
listState = listState, listState = listState,
immutableMessages = ImmutableList.copyOf(screenState.messages), immutableMessages = ImmutableList.copyOf(screenState.messages),
isPaginating = screenState.isPaginating, isPaginating = screenState.isPaginating,
enableAnimations = animationsEnabled,
messageBarHeight = messageBarHeight, messageBarHeight = messageBarHeight,
onRequestScrollToCmId = { cmId -> onRequestScrollToCmId = { cmId ->
coroutineScope.launch { coroutineScope.launch {
@@ -13,12 +13,14 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.haze
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
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
@@ -30,11 +32,15 @@ fun MessagesList(
listState: LazyListState, listState: LazyListState,
immutableMessages: ImmutableList<UiItem>, immutableMessages: ImmutableList<UiItem>,
isPaginating: Boolean, isPaginating: Boolean,
enableAnimations: Boolean,
messageBarHeight: Dp, messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Int) -> Unit = {} onRequestScrollToCmId: (cmId: Int) -> Unit = {}
) { ) {
val messages = immutableMessages.toList() val enableAnimations = remember {
AppSettings.Experimental.moreAnimations
}
val messages = remember(immutableMessages) {
immutableMessages.toList()
}
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
LazyColumn( LazyColumn(
@@ -387,16 +387,22 @@ class SettingsViewModelImpl(
title = UiText.Resource(UiR.string.settings_features_long_poll_in_background_title), title = UiText.Resource(UiR.string.settings_features_long_poll_in_background_title),
text = UiText.Resource(UiR.string.settings_features_long_poll_in_background_summary) 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( val experimentalUseBlur = SettingsItem.Switch(
key = SettingsKeys.KEY_USE_BLUR, key = SettingsKeys.KEY_USE_BLUR,
defaultValue = SettingsKeys.DEFAULT_USE_BLUR, defaultValue = SettingsKeys.DEFAULT_USE_BLUR,
title = UiText.Simple("Use blur"), title = UiText.Simple("Use blur"),
text = UiText.Simple("Adds blur wherever possible\nWorks on android 12 and newer"), text = UiText.Simple("Adds blur wherever possible\nWorks on android 12 and newer"),
) )
val experimentalShowTimeInActionMessages = SettingsItem.Switch( val enableAnimations = SettingsItem.Switch(
key = SettingsKeys.KEY_SHOW_TIME_IN_ACTION_MESSAGES, key = SettingsKeys.KEY_MORE_ANIMATIONS,
defaultValue = SettingsKeys.DEFAULT_SHOW_TIME_IN_ACTION_MESSAGES, defaultValue = SettingsKeys.DEFAULT_MORE_ANIMATIONS,
title = UiText.Simple("Show time in action messages") title = UiText.Simple("More animations"),
text = UiText.Simple("Use animations wherever possible")
) )
val debugTitle = SettingsItem.Title( val debugTitle = SettingsItem.Title(
@@ -473,7 +479,8 @@ class SettingsViewModelImpl(
experimentalTitle, experimentalTitle,
experimentalLongPollBackground, experimentalLongPollBackground,
experimentalShowTimeInActionMessages, experimentalShowTimeInActionMessages,
experimentalUseBlur experimentalUseBlur,
enableAnimations
) )
val debugList = mutableListOf<SettingsItem<*>>() val debugList = mutableListOf<SettingsItem<*>>()
listOf( listOf(