17 Commits

Author SHA1 Message Date
melod1n bb40d1b36c Merge branch 'master' into dev 2025-03-23 17:55:40 +03:00
melod1n 0eb3146428 Release 0.1.9 (#140)
* improvements in longpoll's stuff
2025-03-23 17:55:28 +03:00
melod1n e645448852 more improvements 2025-03-23 17:37:13 +03:00
melod1n 314ff806c0 improvements in longpoll's stuff 2025-03-23 17:27:46 +03:00
melod1n 3beb382334 Merge remote-tracking branch 'origin/dev' into dev 2025-03-23 11:53:51 +03:00
melod1n 5b5ba747d8 some updates 2025-03-23 11:53:28 +03:00
melod1n b8937a1590 Merge branch 'master' into dev 2025-03-23 09:23:38 +03:00
melod1n b2879d8756 Release 0.1.8 (#139)
* pagination in chat fixed
* other fixes and improvements

* fixed visual bug in progress bar in chat history

* 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`.

* Chat creation feature (#138)

* - read indicator, edit status and time for message in messages history

* message sending status
2025-03-23 09:22:41 +03:00
melod1n a4feb8978f message sending status 2025-03-23 09:08:29 +03:00
melod1n 79f539a27b - read indicator, edit status and time for message in messages history 2025-03-23 08:45:01 +03:00
melod1n 4cc6ec6b5d Chat creation feature (#138) 2025-03-23 07:33:58 +03:00
melod1n 36a119ffa9 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`.
2025-03-21 12:43:22 +03:00
melod1n 1a78a51017 * fixed visual bug in progress bar in chat history 2025-03-21 04:49:17 +03:00
melod1n cbe3313b87 * pagination in chat fixed
* other fixes and improvements
2025-03-21 04:44:09 +03:00
melod1n 30e132d418 Release 0.1.7 (#136)
* Bump haze from 1.1.1 to 1.2.0 (#105)

* Bump org.jetbrains.kotlinx:kotlinx-serialization-json from 1.7.3 to 1.8.0 (#104)

* update gradle wrapper

* Bump agp from 8.7.3 to 8.8.0 (#106)

* Bump com.jraska.module.graph.assertion from 2.7.1 to 2.7.3 (#109)

* Bump haze from 1.2.0 to 1.2.2 (#111)

* Bump koin from 4.0.1 to 4.0.2 (#112)

* little improvement

* Bump kotlin from 2.1.0 to 2.1.10 (#113)

* Bump androidx.compose:compose-bom from 2024.12.01 to 2025.02.00 (#115)

* Bump androidx.navigation:navigation-compose from 2.8.5 to 2.8.7 (#119)

* Bump haze from 1.2.2 to 1.3.1 (#118)

* Bump ksp from 2.1.0-1.0.29 to 2.1.10-1.0.30 (#116)

* Bump agp from 8.8.0 to 8.8.1 (#117)

* Bump com.google.accompanist:accompanist-permissions (#121)

* Rename the app's namespace and applicationId to `dev.meloda.fastvk`, and update the package name in `ACTION_MANAGE_UNKNOWN_APP_SOURCES` intent. Remove unnecessary `onLowMemory` method in the `OnlineService`.

* Bump com.jraska.module.graph.assertion from 2.7.3 to 2.8.0 (#126)

* Bump ksp from 2.1.10-1.0.30 to 2.1.10-1.0.31 (#125)

* Bump haze from 1.3.1 to 1.4.0 (#124)

* Bump agp from 8.8.1 to 8.8.2 (#123)

* Bump androidx.navigation:navigation-compose from 2.8.7 to 2.8.8 (#122)

* Bump haze from 1.4.0 to 1.5.0 (#128)

* Bump agp from 8.8.2 to 8.9.0 (#127)

* Bump androidx.navigation:navigation-compose from 2.8.8 to 2.8.9 (#130)

* Bump androidx.compose:compose-bom from 2025.02.00 to 2025.03.00 (#129)

* revert agp version to 8.8.2

* fix issues with package names

* Bump haze from 1.5.0 to 1.5.1 (#133)

* Bump com.google.guava:guava from 33.4.0-jre to 33.4.5-jre (#132)

* russian translations

* fixes and improvements

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-21 03:13:17 +03:00
melod1n 7e5843759d release/0.1.6 (#103)
* Bump com.google.guava:guava from 33.3.1-jre to 33.4.0-jre (#97)

* Bump coroutines from 1.9.0 to 1.10.1 (#100)

* some improvements + loading conversation on new message if it is not already in the list

* Bump koin from 4.0.0 to 4.0.1 (#101)

* minor update

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-11 06:38:08 +03:00
melod1n 7c14df1824 release 0.1.5 (#98)
* settings reorganization;
implement long press on emoji button for fast text;
some deprecations fixed;
some typos fixed;
etc

* 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

* version up
2024-12-17 21:07:22 +03:00
107 changed files with 2825 additions and 954 deletions
+3 -2
View File
@@ -7,10 +7,10 @@ plugins {
} }
android { android {
namespace = "dev.meloda.fast" namespace = "dev.meloda.fastvk"
defaultConfig { defaultConfig {
applicationId = "dev.meloda.fast" applicationId = "dev.meloda.fastvk"
versionCode = libs.versions.versionCode.get().toInt() versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get() versionName = libs.versions.versionName.get()
@@ -77,6 +77,7 @@ dependencies {
implementation(projects.feature.friends) implementation(projects.feature.friends)
implementation(projects.feature.profile) implementation(projects.feature.profile)
implementation(projects.feature.photoviewer) implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.ui) implementation(projects.core.ui)
+3 -3
View File
@@ -22,7 +22,7 @@
tools:targetApi="tiramisu"> tools:targetApi="tiramisu">
<activity <activity
android:name=".presentation.MainActivity" android:name="dev.meloda.fast.presentation.MainActivity"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
@@ -38,13 +38,13 @@
</activity> </activity>
<service <service
android:name=".service.longpolling.LongPollingService" android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service <service
android:name=".service.OnlineService" android:name="dev.meloda.fast.service.OnlineService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
@@ -86,6 +86,8 @@ class MainViewModelImpl(
BaseError.SessionExpired -> { BaseError.SessionExpired -> {
isNeedToReplaceWithAuth.update { true } isNeedToReplaceWithAuth.update { true }
} }
is BaseError.SimpleError -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui
} }
} }
@@ -16,6 +16,7 @@ import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.conversations.di.conversationsModule import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.conversations.di.createChatModule
import dev.meloda.fast.domain.di.domainModule import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule import dev.meloda.fast.languagepicker.di.languagePickerModule
@@ -26,8 +27,8 @@ import dev.meloda.fast.provider.ApiLanguageProvider
import dev.meloda.fast.service.longpolling.di.longPollModule import dev.meloda.fast.service.longpolling.di.longPollModule
import dev.meloda.fast.settings.di.settingsModule import dev.meloda.fast.settings.di.settingsModule
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.qualifier import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
@@ -46,7 +47,8 @@ val applicationModule = module {
longPollModule, longPollModule,
friendsModule, friendsModule,
profileModule, profileModule,
chatMaterialsModule chatMaterialsModule,
createChatModule
) )
// TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors // TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors
@@ -61,7 +63,7 @@ val applicationModule = module {
qualifier = qualifier("main") qualifier = qualifier("main")
} }
single { single<ImageLoader> {
ImageLoader.Builder(get()) ImageLoader.Builder(get())
.crossfade(true) .crossfade(true)
.build() .build()
@@ -24,6 +24,8 @@ 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,
onCreateChatClicked: () -> Unit,
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
@@ -54,6 +56,8 @@ fun NavGraphBuilder.mainScreen(
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onConversationItemClicked = onConversationClicked, onConversationItemClicked = onConversationClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
onCreateChatClicked = onCreateChatClicked,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -33,7 +33,7 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeEffect
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.MainViewModel import dev.meloda.fast.MainViewModel
@@ -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,8 @@ 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 = {},
onCreateChatClicked: () -> Unit = {},
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -70,21 +70,13 @@ 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(
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()
) )
@@ -108,8 +100,6 @@ fun MainScreen(
inclusive = true inclusive = true
} }
} }
} else {
sharedFlow.tryEmit(index)
} }
}, },
icon = { icon = {
@@ -176,13 +166,14 @@ 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, onCreateChatClicked = onCreateChatClicked,
navController = navController, navController = navController,
) )
profileScreen( profileScreen(
@@ -25,6 +25,8 @@ import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
@@ -124,6 +126,8 @@ 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,
onCreateChatClicked = navController::navigateToCreateChat,
viewModel = viewModel viewModel = viewModel
) )
@@ -136,6 +140,13 @@ fun RootScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
) )
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen( settingsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
@@ -95,11 +95,6 @@ class OnlineService : Service() {
}.also { coroutine -> coroutine.invokeOnCompletion { onlineJob = null } } }.also { coroutine -> coroutine.invokeOnCompletion { onlineJob = null } }
} }
override fun onLowMemory() {
Log.d(STATE_TAG, "onLowMemory")
super.onLowMemory()
}
override fun onDestroy() { override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy") Log.d(STATE_TAG, "onDestroy")
@@ -16,11 +16,11 @@ import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
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.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@@ -249,6 +249,7 @@ class LongPollingService : Service() {
override fun onDestroy() { override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy") Log.d(STATE_TAG, "onDestroy")
longPollController.updateCurrentState(LongPollState.Stopped) longPollController.updateCurrentState(LongPollState.Stopped)
updatesParser.clearListeners()
try { try {
AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) } AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) }
job.cancel() job.cancel()
@@ -259,8 +260,7 @@ class LongPollingService : Service() {
} }
override fun onTrimMemory(level: Int) { override fun onTrimMemory(level: Int) {
Log.d(STATE_TAG, "onTrimMemory") Log.d(STATE_TAG, "onTrimMemory. Level: $level")
longPollController.updateCurrentState(LongPollState.Stopped)
super.onTrimMemory(level) super.onTrimMemory(level)
} }
@@ -1,7 +1,5 @@
package dev.meloda.fast.common.extensions package dev.meloda.fast.common.extensions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -86,7 +86,7 @@ object AndroidUtils {
action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Settings.ACTION_SECURITY_SETTINGS Settings.ACTION_SECURITY_SETTINGS
} else { } else {
data = Uri.parse("package:dev.meloda.fast") data = Uri.parse("package:dev.meloda.fastvk")
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES
} }
}) })
@@ -20,18 +20,20 @@ sealed class State<out T> {
data object ConnectionError : Error() data object ConnectionError : Error()
data object Unknown : Error() data object UnknownError : Error()
data object InternalError : Error() data object InternalError : Error()
data class OAuthError(val error: OAuthErrorDomain) : Error() data class OAuthError(val error: OAuthErrorDomain) : Error()
data class TestError(val message: String) : Error()
} }
fun isLoading(): Boolean = this is Loading fun isLoading(): Boolean = this is Loading
companion object { companion object {
val UNKNOWN_ERROR = Error.Unknown val UNKNOWN_ERROR = Error.UnknownError
} }
} }
@@ -73,11 +75,12 @@ fun <T : Any> ApiResult<T, RestApiErrorDomain>.mapToState() = when (this) {
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError() is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
} }
fun <T : Any, N> ApiResult<T, RestApiErrorDomain>.mapToState(successMapper: (T) -> N) = when (this) { fun <T : Any, N> ApiResult<T, RestApiErrorDomain>.mapToState(successMapper: (T) -> N) =
is ApiResult.Success -> State.Success(successMapper(this.value)) when (this) {
is ApiResult.Success -> State.Success(successMapper(this.value))
is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError
is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR
is ApiResult.Failure.HttpFailure -> this.error.toStateApiError() is ApiResult.Failure.HttpFailure -> this.error.toStateApiError()
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError() is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
} }
@@ -1,10 +1,10 @@
package dev.meloda.fast.data.api.messages package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface MessagesRepository { interface MessagesRepository {
@@ -41,6 +41,11 @@ interface MessagesRepository {
conversationMessageId: Int conversationMessageId: Int
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain> ): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain>
suspend fun createChat(
userIds: List<Int>?,
title: String?
): ApiResult<Int, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -1,5 +1,6 @@
package dev.meloda.fast.data.api.messages package dev.meloda.fast.data.api.messages
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
@@ -14,6 +15,7 @@ import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
@@ -23,7 +25,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.messages.MessagesService import dev.meloda.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -198,6 +199,23 @@ class MessagesRepositoryImpl(
) )
} }
override suspend fun createChat(
userIds: List<Int>?,
title: String?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesCreateChatRequest(
userIds = userIds,
title = title
)
messagesService.createChat(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().chatId
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun storeMessages(messages: List<VkMessage>) { override suspend fun storeMessages(messages: List<VkMessage>) {
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
@@ -20,4 +20,8 @@ class GetLocalUserByIdUseCase(private val repository: UsersRepository) {
emit(newState) emit(newState)
} }
suspend fun proceed(userId: Int): VkUser? {
return repository.getLocalUsers(userIds = listOf(userId)).singleOrNull()
}
} }
@@ -11,11 +11,15 @@ import dev.meloda.fast.data.processState
import dev.meloda.fast.model.ApiEvent import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -25,39 +29,37 @@ class LongPollUpdatesParser(
) { ) {
private val job = SupervisorJob() private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> private val exceptionHandler =
Log.d("LongPollUpdatesParser", "error: $throwable") CoroutineExceptionHandler { _, throwable ->
throwable.printStackTrace() Log.e("LongPollUpdatesParser", "error: $throwable")
} throwable.printStackTrace()
}
private val coroutineContext: CoroutineContext private val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job + exceptionHandler get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val listenersMap: MutableMap<ApiEvent, MutableCollection<VkEventCallback<*>>> = private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf() mutableMapOf()
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)
ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event) ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event)
ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event) ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event)
ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event) ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event)
ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event)
ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event)
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event) ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
ApiEvent.PIN_UNPIN_CONVERSATION -> parseConversationPinStateChanged(eventType, event) ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING, ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING, ApiEvent.AUDIO_MESSAGE_RECORDING,
@@ -65,14 +67,10 @@ class LongPollUpdatesParser(
ApiEvent.VIDEO_UPLOADING, ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event) ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> onNewEvent(eventType, event) ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
} }
} }
private fun onNewEvent(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event")
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) { private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
@@ -85,6 +83,15 @@ class LongPollUpdatesParser(
else -> return else -> return
} }
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
else -> return
}
val peerId = event[1].asInt() val peerId = event[1].asInt()
val userIds = event[2].toList(Any::asInt).filter { it != UserConfig.userId } val userIds = event[2].toList(Any::asInt).filter { it != UserConfig.userId }
val totalCount = event[3].asInt() val totalCount = event[3].asInt()
@@ -93,51 +100,227 @@ class LongPollUpdatesParser(
// if userIds contains only account's id, then we don't need to show our status // if userIds contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return if (userIds.isEmpty()) return
coroutineScope.launch { listenersMap[longPollEvent]?.let { listeners ->
listenersMap[eventType]?.let { listeners -> listeners.forEach { vkEventCallback ->
listeners.forEach { vkEventCallback -> (vkEventCallback as VkEventCallback<LongPollParsedEvent.Interaction>)
(vkEventCallback as VkEventCallback<LongPollEvent.Interaction>) .onEvent(
.onEvent( LongPollParsedEvent.Interaction(
LongPollEvent.Interaction( interactionType = interactionType,
interactionType = interactionType, peerId = peerId,
peerId = peerId, userIds = userIds,
userIds = userIds, totalCount = totalCount,
totalCount = totalCount, timestamp = timestamp
timestamp = timestamp
)
) )
} )
} }
} }
} }
private fun parseConversationPinStateChanged(eventType: ApiEvent, event: List<Any>) { private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType $event")
val peerId = event[1].asInt() val unreadCount = event[1].asInt()
val majorId = event[2].asInt() val unreadUnmutedCount = event[2].asInt()
val showOnlyMuted = event[3].asInt() == 1
val businessNotifyUnreadCount = event[4].asInt()
val archiveUnreadCount = event[7].asInt()
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
coroutineScope.launch { listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.let { listeners ->
listenersMap[ApiEvent.PIN_UNPIN_CONVERSATION]?.let { listeners -> listeners.forEach { vkEventCallback ->
listeners.forEach { vkEventCallback -> (vkEventCallback as VkEventCallback<LongPollParsedEvent.UnreadCounter>)
(vkEventCallback as VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>) .onEvent(
.onEvent( LongPollParsedEvent.UnreadCounter(
LongPollEvent.VkConversationPinStateChangedEvent( unread = unreadCount,
peerId = peerId, unreadUnmuted = unreadUnmutedCount,
majorId = majorId showOnlyMuted = showOnlyMuted,
) business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
) )
} )
} }
} }
} }
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) { private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
val messageId = event[1].asInt()
val flags = event[2].asInt()
val peerId = event[3].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> { // marked as important
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
messageId = messageId,
marked = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> { // marked as spam
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
messageId = messageId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.DELETED -> {
val eventToSend =
if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) { // deleted for all
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
messageId = messageId,
forAll = true
)
} else { // deleted only for me
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
messageId = messageId,
forAll = false
)
}
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.AUDIO_LISTENED -> { // audio message listened
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
messageId = messageId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend)
}
}
}
} }
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) { private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
val messageId = event[1].asInt()
val flags = event[2].asInt()
val peerId = event[3].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> { // not important anymore
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
messageId = messageId,
marked = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) { // not spam anymore
withContext(Dispatchers.IO) {
val message = loadMessage(messageId)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsNotSpam>)
?.onEvent(eventToSend)
}
}
}
}
}
}
MessageFlags.DELETED -> { // restored
withContext(Dispatchers.IO) {
val message = loadMessage(messageId)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageRestored>)
?.onEvent(eventToSend)
}
}
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
}
} }
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) { private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
@@ -145,17 +328,11 @@ class LongPollUpdatesParser(
val messageId = event[1].asInt() val messageId = event[1].asInt()
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
val newMessageEvent: LongPollEvent.VkMessageNewEvent? = loadMessage(messageId)?.let { message ->
loadNormalMessage( listenersMap[LongPollEvent.MESSAGE_NEW]?.let {
eventType,
messageId
)
newMessageEvent?.let { event ->
listenersMap[ApiEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback -> it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageNewEvent>) (vkEventCallback as VkEventCallback<LongPollParsedEvent.NewMessage>)
.onEvent(event) .onEvent(LongPollParsedEvent.NewMessage(message))
} }
} }
} }
@@ -166,18 +343,12 @@ class LongPollUpdatesParser(
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
val messageId = event[1].asInt() val messageId = event[1].asInt()
coroutineScope.launch { coroutineScope.launch(Dispatchers.IO) {
val editedMessageEvent: LongPollEvent.VkMessageEditEvent? = loadMessage(messageId)?.let { message ->
loadNormalMessage( listenersMap[LongPollEvent.MESSAGE_EDITED]?.let {
eventType,
messageId
)
editedMessageEvent?.let { event ->
listenersMap[ApiEvent.MESSAGE_EDIT]?.let {
it.map { vkEventCallback -> it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageEditEvent>) (vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageEdited>)
.onEvent(event) .onEvent(LongPollParsedEvent.MessageEdited(message))
} }
} }
} }
@@ -190,18 +361,16 @@ class LongPollUpdatesParser(
val messageId = event[2].asInt() val messageId = event[2].asInt()
val unreadCount = event[3].asInt() val unreadCount = event[3].asInt()
coroutineScope.launch { listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners ->
listenersMap[ApiEvent.MESSAGE_READ_INCOMING]?.let { listeners -> listeners.map { vkEventCallback ->
listeners.map { vkEventCallback -> (vkEventCallback as VkEventCallback<LongPollParsedEvent.IncomingMessageRead>)
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>) .onEvent(
.onEvent( LongPollParsedEvent.IncomingMessageRead(
LongPollEvent.VkMessageReadIncomingEvent( peerId = peerId,
peerId = peerId, messageId = messageId,
messageId = messageId, unreadCount = unreadCount
unreadCount = unreadCount
)
) )
} )
} }
} }
} }
@@ -212,30 +381,86 @@ class LongPollUpdatesParser(
val messageId = event[2].asInt() val messageId = event[2].asInt()
val unreadCount = event[3].asInt() val unreadCount = event[3].asInt()
coroutineScope.launch { listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners ->
listenersMap[ApiEvent.MESSAGE_READ_OUTGOING]?.let { listeners -> listeners.map { vkEventCallback ->
listeners.map { vkEventCallback -> (vkEventCallback as VkEventCallback<LongPollParsedEvent.OutgoingMessageRead>)
(vkEventCallback as VkEventCallback<LongPollEvent.VkMessageReadOutgoingEvent>) .onEvent(
.onEvent( LongPollParsedEvent.OutgoingMessageRead(
LongPollEvent.VkMessageReadOutgoingEvent( peerId = peerId,
peerId = peerId, messageId = messageId,
messageId = messageId, unreadCount = unreadCount
unreadCount = unreadCount
)
) )
} )
} }
} }
} }
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) { private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
} }
private suspend inline fun <reified T : LongPollEvent> loadNormalMessage( private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
eventType: ApiEvent, Log.d("LongPollUpdatesParser", "$eventType: $event")
messageId: Int }
): T? = suspendCoroutine { continuation ->
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val messageId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatCleared>)
.onEvent(
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toMessageId = messageId
)
)
}
}
}
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val majorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMajorChanged>)
.onEvent(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
)
}
}
}
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asInt()
val minorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMinorChanged>)
.onEvent(
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
)
}
}
}
private suspend fun loadMessage(messageId: Int): VkMessage? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById( messagesUseCase.getById(
messageIds = listOf(messageId), messageIds = listOf(messageId),
@@ -244,10 +469,11 @@ class LongPollUpdatesParser(
).listenValue(this) { state -> ).listenValue(this) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.e("LongPollUpdatesParser", "loadNormalMessage: error: $error") Log.e("LongPollUpdatesParser", "loadMessage: error: $error")
continuation.resume(null)
}, },
success = { messages -> success = { response ->
val message = messages.singleOrNull() ?: run { val message = response.singleOrNull() ?: run {
continuation.resume(null) continuation.resume(null)
return@listenValue return@listenValue
} }
@@ -255,107 +481,113 @@ class LongPollUpdatesParser(
VkMemoryCache[message.id] = message VkMemoryCache[message.id] = message
messagesUseCase.storeMessage(message) messagesUseCase.storeMessage(message)
val resumeValue: LongPollEvent? = when (eventType) { continuation.resume(message)
ApiEvent.MESSAGE_NEW -> LongPollEvent.VkMessageNewEvent(message)
ApiEvent.MESSAGE_EDIT -> LongPollEvent.VkMessageEditEvent(message)
else -> {
continuation.resume(null)
null
}
}
resumeValue?.let { value -> continuation.resume(value as T) }
} }
) )
} }
} }
} }
private fun <T : Any> registerListener( @Suppress("UNCHECKED_CAST")
eventType: ApiEvent, private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T> listener: VkEventCallback<T>
) { ) {
listenersMap.let { map -> listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf()).also { it.add(listener) } map[eventType] = (map[eventType] ?: mutableListOf())
.also {
it.add(listener as VkEventCallback<LongPollParsedEvent>)
}
} }
} }
private fun <T : Any> registerListeners( private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<ApiEvent>, eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T> listener: VkEventCallback<T>
) { ) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) } eventTypes.forEach { eventType -> registerListener(eventType, listener) }
} }
fun onConversationPinStateChanged(listener: VkEventCallback<LongPollEvent.VkConversationPinStateChangedEvent>) { fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(ApiEvent.PIN_UNPIN_CONVERSATION, listener) registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
} }
fun onConversationPinStateChanged(block: (LongPollEvent.VkConversationPinStateChangedEvent) -> Unit) { fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
onConversationPinStateChanged(assembleEventCallback(block)) registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
} }
fun onMessageIncomingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadIncomingEvent>) { fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(ApiEvent.MESSAGE_READ_INCOMING, listener) registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
} }
fun onMessageIncomingRead(block: (LongPollEvent.VkMessageReadIncomingEvent) -> Unit) { fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
onMessageIncomingRead(assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
} }
fun onMessageOutgoingRead(listener: VkEventCallback<LongPollEvent.VkMessageReadOutgoingEvent>) { fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(ApiEvent.MESSAGE_READ_OUTGOING, listener) registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
} }
fun onMessageOutgoingRead(block: (LongPollEvent.VkMessageReadOutgoingEvent) -> Unit) { fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
onMessageOutgoingRead(assembleEventCallback(block)) registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
} }
fun onNewMessage(listener: VkEventCallback<LongPollEvent.VkMessageNewEvent>) { fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(ApiEvent.MESSAGE_NEW, listener) registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
} }
fun onNewMessage(block: (LongPollEvent.VkMessageNewEvent) -> Unit) { fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
onNewMessage(assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
} }
fun onMessageEdited(listener: VkEventCallback<LongPollEvent.VkMessageEditEvent>) { fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(ApiEvent.MESSAGE_EDIT, listener) registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
} }
fun onMessageEdited(block: (LongPollEvent.VkMessageEditEvent) -> Unit) { fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
onMessageEdited(assembleEventCallback(block)) registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
} }
fun onInteractions(listener: VkEventCallback<LongPollEvent.Interaction>) { fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
}
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
}
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
}
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
}
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners( registerListeners(
eventTypes = listOf( eventTypes = listOf(
ApiEvent.TYPING, LongPollEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING, LongPollEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING, LongPollEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING, LongPollEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING LongPollEvent.FILE_UPLOADING
), ),
listener = listener listener = assembleEventCallback(block)
) )
} }
fun onInteractions(block: (LongPollEvent.Interaction) -> Unit) {
onInteractions(assembleEventCallback(block))
}
fun clearListeners() { fun clearListeners() {
listenersMap.clear() listenersMap.clear()
} }
} }
internal inline fun <R : Any> assembleEventCallback( internal inline fun <R : LongPollParsedEvent> assembleEventCallback(
crossinline block: (R) -> Unit, crossinline block: (R) -> Unit,
): VkEventCallback<R> { ): VkEventCallback<R> {
return VkEventCallback { event -> block.invoke(event) } return VkEventCallback { event -> block.invoke(event) }
} }
fun interface VkEventCallback<in T : Any> { fun interface VkEventCallback<in T : LongPollParsedEvent> {
fun onEvent(event: T) fun onEvent(event: T)
} }
@@ -42,6 +42,11 @@ interface MessagesUseCase {
conversationMessageId: Int conversationMessageId: Int
): Flow<State<List<VkAttachmentHistoryMessage>>> ): Flow<State<List<VkAttachmentHistoryMessage>>>
fun createChat(
userIds: List<Int>?,
title: String?
): Flow<State<Int>>
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
} }
@@ -100,6 +100,14 @@ class MessagesUseCaseImpl(
emit(newState) emit(newState)
} }
override fun createChat(userIds: List<Int>?, title: String?): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.createChat(userIds, title).mapToState()
emit(newState)
}
override suspend fun storeMessage(message: VkMessage) { override suspend fun storeMessage(message: VkMessage) {
repository.storeMessages(listOf(message)) repository.storeMessages(listOf(message))
} }
@@ -33,92 +33,100 @@ class OAuthUseCaseImpl(
forceSms = forceSms forceSms = forceSms
) )
val error = response.error?.let(VkOAuthError::parse) kotlin.runCatching {
val errorType = response.errorType?.let(VkOAuthErrorType::parse) val error = response.error?.let(VkOAuthError::parse)
val errorType = response.errorType?.let(VkOAuthErrorType::parse)
val newState = when (error) { val newState = when (error) {
null -> { null -> {
State.Success( State.Success(
AuthInfo( AuthInfo(
userId = response.userId, userId = response.userId,
accessToken = response.accessToken, accessToken = response.accessToken,
validationHash = response.validationHash validationHash = response.validationHash
)
)
}
VkOAuthError.FLOOD_CONTROL -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthError.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
State.Error.OAuthError(
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
)
} else {
State.Error.OAuthError(
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
) )
) )
} }
}
VkOAuthError.NEED_CAPTCHA -> { VkOAuthError.FLOOD_CONTROL -> {
State.Error.OAuthError( State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
OAuthErrorDomain.CaptchaRequiredError( }
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty() VkOAuthError.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
State.Error.OAuthError(
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
)
} else {
State.Error.OAuthError(
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
)
)
}
}
VkOAuthError.NEED_CAPTCHA -> {
State.Error.OAuthError(
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
)
) )
) }
}
VkOAuthError.INVALID_CLIENT -> { VkOAuthError.INVALID_CLIENT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError) State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
} }
VkOAuthError.INVALID_REQUEST -> { VkOAuthError.INVALID_REQUEST -> {
when (errorType) { when (errorType) {
null -> State.Error.OAuthError(OAuthErrorDomain.UnknownError) null -> State.Error.OAuthError(OAuthErrorDomain.UnknownError)
VkOAuthErrorType.WRONG_OTP -> { VkOAuthErrorType.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode) State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode)
}
VkOAuthErrorType.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat)
}
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
} }
}
VkOAuthErrorType.WRONG_OTP_FORMAT -> { VkOAuthError.UNKNOWN -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat) State.Error.OAuthError(OAuthErrorDomain.UnknownError)
}
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
} }
} }
VkOAuthError.UNKNOWN -> { emit(newState)
State.Error.OAuthError(OAuthErrorDomain.UnknownError) }.fold(
onSuccess = {
},
onFailure = {
emit(State.Error.TestError(it.stackTraceToString()))
} }
} )
emit(newState)
} }
} }
@@ -1,9 +1,9 @@
package dev.meloda.fast.friends.util package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.api.UiFriend
fun VkUser.asPresentation( fun VkUser.asPresentation(
useContactNames: Boolean = false useContactNames: Boolean = false
@@ -16,5 +16,7 @@ fun VkUser.asPresentation(
fullName fullName
}, },
onlineStatus = onlineStatus, onlineStatus = onlineStatus,
photo400Orig = photo400Orig?.let(UiImage::Url) photo400Orig = photo400Orig?.let(UiImage::Url),
firstName = firstName,
lastName = lastName
) )
@@ -7,8 +7,11 @@ enum class ApiEvent(val value: Int) {
MESSAGE_EDIT(5), MESSAGE_EDIT(5),
MESSAGE_READ_INCOMING(6), MESSAGE_READ_INCOMING(6),
MESSAGE_READ_OUTGOING(7), MESSAGE_READ_OUTGOING(7),
CHAT_CLEAR_FLAGS(10),
CHAT_SET_FLAGS(12),
MESSAGES_DELETED(13), MESSAGES_DELETED(13),
PIN_UNPIN_CONVERSATION(20), CHAT_MAJOR_CHANGED(20),
CHAT_MINOR_CHANGED(21),
TYPING(63), TYPING(63),
AUDIO_MESSAGE_RECORDING(64), AUDIO_MESSAGE_RECORDING(64),
PHOTO_UPLOADING(65), PHOTO_UPLOADING(65),
@@ -18,5 +21,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 }
} }
} }
@@ -6,4 +6,6 @@ import androidx.compose.runtime.Immutable
sealed class BaseError { sealed class BaseError {
data object SessionExpired : BaseError() data object SessionExpired : BaseError()
data class SimpleError(val message: String) : BaseError()
} }
@@ -0,0 +1,17 @@
package dev.meloda.fast.model
enum class ConversationFlags(val value: Int) {
DISABLE_PUSH(16),
DISABLE_SOUND(32),
INCOMING_CHAT_REQUEST(256),
DECLINED_CHAT_REQUEST(512),
MENTION(1024),
HIDE_CHAT_FROM_SEARCH(2048),
BUSINESS_CHAT(8192),
MARKED_MESSAGE(16384), // mention or disappearing message
DO_NOT_NOTIFY_MENTIONS_ALL_ONLINE(262144),
DO_NOT_NOTIFY_ALL_MENTIONS(524288),
MARKED_AS_UNREAD(1048576),
ARCHIVED(8388608),
CALL_IN_PROGRESS(16777216),
}
@@ -1,35 +1,27 @@
package dev.meloda.fast.model package dev.meloda.fast.model
import dev.meloda.fast.model.api.domain.VkMessage enum class LongPollEvent {
MESSAGE_SET_FLAGS,
sealed interface LongPollEvent { MESSAGE_CLEAR_FLAGS,
MESSAGE_NEW,
data class VkMessageNewEvent(val message: VkMessage) : LongPollEvent MESSAGE_EDITED,
INCOMING_MESSAGE_READ,
data class VkMessageEditEvent(val message: VkMessage) : LongPollEvent OUTGOING_MESSAGE_READ,
CHAT_SET_FLAGS,
data class VkMessageReadIncomingEvent( CHAT_CLEAR_FLAGS,
val peerId: Int, CHAT_MAJOR_CHANGED,
val messageId: Int, CHAT_MINOR_CHANGED,
val unreadCount: Int, TYPING,
) : LongPollEvent AUDIO_MESSAGE_RECORDING,
PHOTO_UPLOADING,
data class VkMessageReadOutgoingEvent( VIDEO_UPLOADING,
val peerId: Int, FILE_UPLOADING,
val messageId: Int, UNREAD_COUNTER_UPDATE,
val unreadCount: Int, MARKED_AS_IMPORTANT,
) : LongPollEvent MARKED_AS_SPAM,
MARKED_AS_NOT_SPAM,
data class VkConversationPinStateChangedEvent( MESSAGE_DELETED,
val peerId: Int, MESSAGE_RESTORED,
val majorId: Int, AUDIO_MESSAGE_LISTENED,
) : LongPollEvent CHAT_CLEARED
data class Interaction(
val interactionType: InteractionType,
val peerId: Int,
val userIds: List<Int>,
val totalCount: Int,
val timestamp: Int
) : LongPollEvent
} }
@@ -0,0 +1,85 @@
package dev.meloda.fast.model
import dev.meloda.fast.model.api.domain.VkMessage
sealed interface LongPollParsedEvent {
data class NewMessage(val message: VkMessage) : LongPollParsedEvent
data class MessageEdited(val message: VkMessage) : LongPollParsedEvent
data class IncomingMessageRead(
val peerId: Int,
val messageId: Int,
val unreadCount: Int,
) : LongPollParsedEvent
data class OutgoingMessageRead(
val peerId: Int,
val messageId: Int,
val unreadCount: Int,
) : LongPollParsedEvent
data class ChatMajorChanged(
val peerId: Int,
val majorId: Int,
) : LongPollParsedEvent
data class ChatMinorChanged(
val peerId: Int,
val minorId: Int
) : LongPollParsedEvent
data class Interaction(
val interactionType: InteractionType,
val peerId: Int,
val userIds: List<Int>,
val totalCount: Int,
val timestamp: Int
) : LongPollParsedEvent
data class UnreadCounter(
val unread: Int,
val unreadUnmuted: Int,
val showOnlyMuted: Boolean,
val business: Int,
val archive: Int,
val archiveUnmuted: Int,
val archiveMentions: Int
) : LongPollParsedEvent
data class MessageMarkedAsImportant(
val peerId: Int,
val messageId: Int,
val marked: Boolean
) : LongPollParsedEvent
data class MessageMarkedAsSpam(
val peerId: Int,
val messageId: Int
) : LongPollParsedEvent
data class MessageMarkedAsNotSpam(
val message: VkMessage
) : LongPollParsedEvent
data class MessageDeleted(
val peerId: Int,
val messageId: Int,
val forAll: Boolean
) : LongPollParsedEvent
data class MessageRestored(
val message: VkMessage
) : LongPollParsedEvent
data class AudioMessageListened(
val peerId: Int,
val messageId: Int
) : LongPollParsedEvent
data class ChatCleared(
val peerId: Int,
val toMessageId: Int
): LongPollParsedEvent
}
@@ -0,0 +1,31 @@
package dev.meloda.fast.model
enum class MessageFlags(val value: Int) {
UNREAD(1),
OUTGOING(2),
IMPORTANT(8),
SPAM(64),
DELETED(128),
AUDIO_LISTENED(4096),
FROM_GROUP_CHAT(8192),
CANCEL_SPAM(32768),
DELETED_FOR_ALL(131072),
DO_NOT_SHOW_NOTIFICATION(1048576),
MESSAGE_WITH_REPLY(2097152),
REACTION(16777216);
companion object {
fun parse(mask: Int): List<MessageFlags> {
val flags = mutableListOf<MessageFlags>()
entries.forEach { flag ->
if (mask and flag.value > 0) {
flags.add(flag)
}
}
return flags
}
}
}
@@ -1,7 +1,7 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import dev.meloda.fast.model.api.domain.VkVideoDomain
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkVideoDomain
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkVideoData( data class VkVideoData(
@@ -12,7 +12,7 @@ data class VkVideoData(
val duration: Int, val duration: Int,
val date: Int, val date: Int,
val comments: Int?, val comments: Int?,
val description: String, val description: String?,
val player: String?, val player: String?,
val added: Int?, val added: Int?,
val type: String, val type: String,
@@ -20,9 +20,9 @@ data class VkVideoData(
val access_key: String?, val access_key: String?,
val owner_id: Int, val owner_id: Int,
val is_favorite: Boolean?, val is_favorite: Boolean?,
val image: List<Image>, val image: List<Image>?,
val first_frame: List<FirstFrame>?, val first_frame: List<FirstFrame>?,
val files: File?, val files: File?
) : VkAttachmentData { ) : VkAttachmentData {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -67,7 +67,7 @@ data class VkVideoData(
fun toDomain() = VkVideoDomain( fun toDomain() = VkVideoDomain(
id = id, id = id,
ownerId = owner_id, ownerId = owner_id,
images = image.map { it.asVideoImage() }, images = image.orEmpty().map { it.asVideoImage() },
firstFrames = first_frame, firstFrames = first_frame,
accessKey = access_key, accessKey = access_key,
title = title title = title
@@ -38,6 +38,41 @@ data class VkConversation(
fun isPinned(): Boolean = majorId > 0 fun isPinned(): Boolean = majorId > 0
fun isInUnread() = inRead - (lastMessageId ?: 0) < 0 fun isInUnread() = inRead - (lastMessageId ?: 0) < 0
fun isOutUnread() = outRead - (lastMessageId ?: 0) < 0 fun isOutUnread() = outRead - (lastMessageId ?: 0) < 0
companion object {
val EMPTY: VkConversation = VkConversation(
id = -1,
localId = -1,
ownerId = null,
title = "...",
photo50 = null,
photo100 = null,
photo200 = null,
isCallInProgress = false,
isPhantom = false,
lastConversationMessageId = -1,
inReadCmId = -1,
outReadCmId = -1,
inRead = -1,
outRead = -1,
lastMessageId = null,
unreadCount = -1,
membersCount = null,
canChangePin = false,
canChangeInfo = false,
majorId = -1,
minorId = -1,
pinnedMessageId = null,
interactionType = -1,
interactionIds = emptyList(),
peerType = PeerType.USER,
lastMessage = null,
pinnedMessage = null,
user = null,
group = null
)
}
} }
fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity( fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity(
@@ -38,12 +38,11 @@ data class VkMessage(
fun isGroup() = fromId < 0 fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation) = fun isRead(conversation: VkConversation): Boolean = when {
if (isOut) { id <= 0 -> false
conversation.outRead - id >= 0 isOut -> conversation.outRead - id >= 0
} else { else -> conversation.inRead - id >= 0
conversation.inRead - id >= 0 }
}
fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty() fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty()
@@ -267,3 +267,14 @@ data class MessagesGetHistoryAttachmentsRequest(
fields?.let { this["fields"] = it } fields?.let { this["fields"] = it }
} }
} }
data class MessagesCreateChatRequest(
val userIds: List<Int>?,
val title: String?
) {
val map = mutableMapOf<String, String>().apply {
userIds?.let { this["user_ids"] = it.joinToString(",") }
title?.let { this["title"] = it }
}
}
@@ -1,5 +1,7 @@
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.VkAttachmentHistoryMessageData import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData
import dev.meloda.fast.model.api.data.VkChatMemberData import dev.meloda.fast.model.api.data.VkChatMemberData
import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkContactData
@@ -7,8 +9,6 @@ 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 MessagesGetHistoryResponse( data class MessagesGetHistoryResponse(
@@ -44,3 +44,9 @@ data class MessagesGetHistoryAttachmentsResponse(
@Json(name = "groups") val groups: List<VkGroupData>?, @Json(name = "groups") val groups: List<VkGroupData>?,
@Json(name = "contacts") val contacts: List<VkContactData>? @Json(name = "contacts") val contacts: List<VkContactData>?
) )
@JsonClass(generateAdapter = true)
data class MessagesCreateChatResponse(
@Json(name = "chat_id") val chatId: Int,
@Json(name = "peer_ids") val peerIds: List<Int>
)
@@ -43,11 +43,22 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
converter.fromJson(successType, string) converter.fromJson(successType, string)
}.fold( }.fold(
onSuccess = { successModel -> onSuccess = { successModel ->
if (successModel is ApiResponse<*>) {
if (successModel.error != null) {
throw ApiException(successModel.error)
}
}
return successModel return successModel
}, },
onFailure = { failure -> onFailure = { failure ->
if(failure is JsonDataException) { if (failure is JsonDataException) {
throw failure throw ApiException(
RestApiError(
errorCode = -1,
errorMsg = failure.message.orEmpty()
)
)
} }
val isUnit = successType == Unit::class.java val isUnit = successType == Unit::class.java
@@ -5,6 +5,8 @@ enum class ValidationType(val value: String) {
SMS("2fa_sms"); SMS("2fa_sms");
companion object { companion object {
fun parse(value: String): ValidationType = entries.first { it.value == value } fun parse(value: String): ValidationType =
entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown validation type $value")
} }
} }
@@ -1,6 +1,7 @@
package dev.meloda.fast.network package dev.meloda.fast.network
enum class VkErrorCode(val code: Int) { enum class VkErrorCode(val code: Int) {
WTF(-1),
UNKNOWN_ERROR(1), UNKNOWN_ERROR(1),
APP_DISABLED(2), APP_DISABLED(2),
UNKNOWN_METHOD(3), UNKNOWN_METHOD(3),
@@ -41,6 +42,8 @@ enum class VkErrorCode(val code: Int) {
ACCESS_TO_DOC_DENIED(1153), ACCESS_TO_DOC_DENIED(1153),
SOME_AUTH_ERROR(104), SOME_AUTH_ERROR(104),
CANNOT_SEND_MESSAGE_DUE_TO_PRIVACY_SETTINGS(902),
ACCESS_TOKEN_EXPIRED(1117); ACCESS_TOKEN_EXPIRED(1117);
companion object { companion object {
@@ -6,7 +6,6 @@ import com.slack.eithernet.integration.retrofit.ApiResultCallAdapterFactory
import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import dev.meloda.fast.common.AppConstants import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.common.model.LogLevel
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.network.JsonConverter import dev.meloda.fast.network.JsonConverter
import dev.meloda.fast.network.MoshiConverter import dev.meloda.fast.network.MoshiConverter
@@ -57,12 +56,8 @@ val networkModule = module {
.followSslRedirects(true) .followSslRedirects(true)
.addInterceptor( .addInterceptor(
HttpLoggingInterceptor().apply { HttpLoggingInterceptor().apply {
level = when (AppSettings.Debug.networkLogLevel) { level =
LogLevel.NONE -> HttpLoggingInterceptor.Level.NONE HttpLoggingInterceptor.Level.entries[AppSettings.Debug.networkLogLevel.ordinal]
LogLevel.BASIC -> HttpLoggingInterceptor.Level.BASIC
LogLevel.HEADERS -> HttpLoggingInterceptor.Level.HEADERS
LogLevel.BODY -> HttpLoggingInterceptor.Level.BODY
}
} }
) )
.build() .build()
@@ -1,12 +1,13 @@
package dev.meloda.fast.network.service.messages package dev.meloda.fast.network.service.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
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
@@ -49,6 +50,12 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError> ): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.CREATE_CHAT)
suspend fun createChat(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesCreateChatResponse>, RestApiError>
// @FormUrlEncoded // @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant) // @POST(MessagesUrls.MarkAsImportant)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -19,4 +19,5 @@ object MessagesUrls {
const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers" const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers"
const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser" const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments" const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments"
const val CREATE_CHAT = "${AppConstants.URL_API}/messages.createChat"
} }
+1
View File
@@ -0,0 +1 @@
/build
+12
View File
@@ -0,0 +1,12 @@
plugins {
alias(libs.plugins.fast.android.library)
alias(libs.plugins.fast.android.library.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "dev.meloda.fast.presentation"
}
dependencies {
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
+1
View File
@@ -11,6 +11,7 @@ android {
dependencies { dependencies {
api(projects.core.common) api(projects.core.common)
api(projects.core.model) api(projects.core.model)
implementation(projects.core.presentation)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
@@ -5,12 +5,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button 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.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
@@ -22,13 +24,16 @@ fun ErrorView(
onButtonClick: (() -> Unit)? = null, onButtonClick: (() -> Unit)? = null,
) { ) {
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
) )
buttonText?.let { buttonText?.let {
@@ -29,9 +29,9 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun IconButton( fun IconButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
@@ -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"
) )
} }
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
enum class ActionState { enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE; PHANTOM, CALL_IN_PROGRESS, NONE;
@@ -0,0 +1,31 @@
package dev.meloda.fast.ui.model.api
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.R
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(R.string.action_mark_as_read),
icon = UiImage.Resource(R.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(R.string.action_pin),
icon = UiImage.Resource(R.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(R.string.action_unpin),
icon = UiImage.Resource(R.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(R.string.action_delete),
icon = UiImage.Resource(R.drawable.round_delete_outline_24)
)
}
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
data class ConversationsShowOptions( data class ConversationsShowOptions(
val showDeleteDialog: Int?, val showDeleteDialog: Int?,
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -1,11 +1,15 @@
package dev.meloda.fast.friends.model package dev.meloda.fast.ui.model.api
import androidx.compose.runtime.Immutable
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.OnlineStatus import dev.meloda.fast.model.api.domain.OnlineStatus
@Immutable
data class UiFriend( data class UiFriend(
val userId: Int, val userId: Int,
val avatar: UiImage?, val avatar: UiImage?,
val firstName: String,
val lastName: String,
val title: String, val title: String,
val onlineStatus: OnlineStatus, val onlineStatus: OnlineStatus,
val photo400Orig: UiImage? val photo400Orig: UiImage?
@@ -1,7 +1,6 @@
package dev.meloda.fast.ui.util package dev.meloda.fast.ui.util
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.drawable.ColorDrawable
import android.os.PowerManager import android.os.PowerManager
import android.view.KeyEvent import android.view.KeyEvent
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@@ -21,6 +20,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toDrawable
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
@@ -64,8 +64,8 @@ fun UiImage.getResourcePainter(): Painter? {
@Composable @Composable
fun UiImage.getImage(): Any { fun UiImage.getImage(): Any {
return when (this) { return when (this) {
is UiImage.Color -> ColorDrawable(color) is UiImage.Color -> color.toDrawable()
is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb()) is UiImage.ColorResource -> colorResource(id = resId).toArgb().toDrawable()
is UiImage.Resource -> painterResource(id = resId) is UiImage.Resource -> painterResource(id = resId)
is UiImage.Simple -> drawable is UiImage.Simple -> drawable
is UiImage.Url -> url is UiImage.Url -> url
@@ -1,5 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,6c1.93,0 3.5,1.57 3.5,3.5S13.93,13 12,13s-3.5,-1.57 -3.5,-3.5S10.07,6 12,6zM12,20c-2.03,0 -4.43,-0.82 -6.14,-2.88C7.55,15.8 9.68,15 12,15s4.45,0.8 6.14,2.12C16.43,19.18 14.03,20 12,20z"/> android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,6c1.93,0 3.5,1.57 3.5,3.5S13.93,13 12,13s-3.5,-1.57 -3.5,-3.5S10.07,6 12,6zM12,20c-2.03,0 -4.43,-0.82 -6.14,-2.88C7.55,15.8 9.68,15 12,15s4.45,0.8 6.14,2.12C16.43,19.18 14.03,20 12,20z" />
</vector> </vector>
@@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z"/> android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z" />
</vector> </vector>
@@ -1,11 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z"/> android:height="24dp"
android:viewportWidth="24"
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M9,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0"/> android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z"/> <path
android:fillColor="#ffffff"
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13z"/> android:fillType="evenOdd"
android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M9,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13z" />
</vector> </vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c-0.55,0 -1,0.45 -1,1v3L8,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h3v3c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-3h3c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-3L13,8c0,-0.55 -0.45,-1 -1,-1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.3,5.71c-0.39,-0.39 -1.02,-0.39 -1.41,0L12,10.59 7.11,5.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41L10.59,12 5.7,16.89c-0.39,0.39 -0.39,1.02 0,1.41 0.39,0.39 1.02,0.39 1.41,0L12,13.41l4.89,4.89c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L13.41,12l4.89,-4.89c0.38,-0.38 0.38,-1.02 0,-1.4z" />
</vector>
@@ -1,7 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z"/> android:height="24dp"
android:viewportWidth="24"
<path android:fillColor="@android:color/white" android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z"/> android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z" />
</vector> </vector>
@@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
<path android:fillColor="@android:color/white" android:pathData="M4,4h16v12L5.17,16L4,17.17L4,4m0,-2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2L4,2zM6,12h8v2L6,14v-2zM6,9h12v2L6,11L6,9zM6,6h12v2L6,8L6,6z"/> android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,4h16v12L5.17,16L4,17.17L4,4m0,-2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2L4,2zM6,12h8v2L6,14v-2zM6,9h12v2L6,11L6,9zM6,6h12v2L6,8L6,6z" />
</vector> </vector>
@@ -1,11 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
<path android:fillColor="@android:color/white" android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z"/> android:height="24dp"
android:viewportWidth="24"
<path android:fillColor="@android:color/white" android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z"/> android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M9,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8C5,10.21 6.79,12 9,12zM9,6c1.1,0 2,0.9 2,2c0,1.1 -0.9,2 -2,2S7,9.1 7,8C7,6.9 7.9,6 9,6z"/> <path
android:fillColor="@android:color/white"
<path android:fillColor="@android:color/white" android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13zM15,18H3l0,-0.99C3.2,16.29 6.3,15 9,15s5.8,1.29 6,2V18z"/> android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z" />
<path
android:fillColor="@android:color/white"
android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8C5,10.21 6.79,12 9,12zM9,6c1.1,0 2,0.9 2,2c0,1.1 -0.9,2 -2,2S7,9.1 7,8C7,6.9 7.9,6 9,6z" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13zM15,18H3l0,-0.99C3.2,16.29 6.3,15 9,15s5.8,1.29 6,2V18z" />
</vector> </vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM11.78,7h-0.06c-0.4,0 -0.72,0.32 -0.72,0.72v4.72c0,0.35 0.18,0.68 0.49,0.86l4.15,2.49c0.34,0.2 0.78,0.1 0.98,-0.24 0.21,-0.34 0.1,-0.79 -0.25,-0.99l-3.87,-2.3L12.5,7.72c0,-0.4 -0.32,-0.72 -0.72,-0.72z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c0.55,0 1,0.45 1,1v4c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1L11,8c0,-0.55 0.45,-1 1,-1zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM13,17h-2v-2h2v2z" />
</vector>
+21 -1
View File
@@ -128,11 +128,12 @@
<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>
<string name="settings_dynamic_colors_description">Цвета для приложения будут извлечены из ваших обоев на главном экране</string> <string name="settings_dynamic_colors_description">Цвета для приложения будут извлечены из ваших обоев на главном экране</string>
<string name="settings_appearance_use_system_font_title">Использовать системный шрифт</string>
<string name="settings_application_language">Язык приложения</string> <string name="settings_application_language">Язык приложения</string>
<string name="settings_application_language_value">Текущий: %1$s</string> <string name="settings_application_language_value">Текущий: %1$s</string>
<string name="language_system">Системный</string> <string name="language_system">Системный</string>
@@ -177,6 +178,7 @@
<string name="settings_general_title">Основное</string> <string name="settings_general_title">Основное</string>
<string name="settings_general_contact_names_title">Использовать имена контактов</string> <string name="settings_general_contact_names_title">Использовать имена контактов</string>
<string name="settings_general_contact_names_summary">Приложение будет использовать доступные имена контактов для пользователей</string> <string name="settings_general_contact_names_summary">Приложение будет использовать доступные имена контактов для пользователей</string>
<string name="settings_general_enable_haptic_title">Включить тактильную отдачу</string>
<string name="settings_appearance_title">Внешний вид</string> <string name="settings_appearance_title">Внешний вид</string>
<string name="settings_appearance_multiline_title">Многострочные заголовки и сообщения</string> <string name="settings_appearance_multiline_title">Многострочные заголовки и сообщения</string>
<string name="settings_appearance_multiline_summary">Заголовок чата и текст сообщения смогут занимать несколько строчек</string> <string name="settings_appearance_multiline_summary">Заголовок чата и текст сообщения смогут занимать несколько строчек</string>
@@ -184,9 +186,11 @@
<string name="settings_features_fast_text_title">Fast текст</string> <string name="settings_features_fast_text_title">Fast текст</string>
<string name="settings_features_long_poll_in_background_title">LongPoll в фоне</string> <string name="settings_features_long_poll_in_background_title">LongPoll в фоне</string>
<string name="settings_features_long_poll_in_background_summary">Ваши сообщения будут обновляться, даже если приложение находится в фоне</string> <string name="settings_features_long_poll_in_background_summary">Ваши сообщения будут обновляться, даже если приложение находится в фоне</string>
<string name="settings_experimental_more_animations_summary">Использовать анимации везде, где возможно</string>
<string name="settings_activity_title">Активность</string> <string name="settings_activity_title">Активность</string>
<string name="settings_activity_send_online_title">Быть «в сети»</string> <string name="settings_activity_send_online_title">Быть «в сети»</string>
<string name="settings_activity_send_online_summary">Статус «в сети» будет отправляться каждые 5 минут</string> <string name="settings_activity_send_online_summary">Статус «в сети» будет отправляться каждые 5 минут</string>
<string name="settings_experimental_title">Экспериментальные - ОЧЕНЬ нестабильные</string>
<string name="settings_debug_title">Отладка</string> <string name="settings_debug_title">Отладка</string>
<string name="action_disable">Отключить</string> <string name="action_disable">Отключить</string>
<string name="background_long_poll_rationale_text">Приложение не сможет обновлять сообщения в фоне без доступа к уведомлениям</string> <string name="background_long_poll_rationale_text">Приложение не сможет обновлять сообщения в фоне без доступа к уведомлениям</string>
@@ -198,4 +202,20 @@
<string name="notification_channel_no_category_description">Уведомления без категории</string> <string name="notification_channel_no_category_description">Уведомления без категории</string>
<string name="notification_channel_long_polling_service_name">Сервис обновления сообщений</string> <string name="notification_channel_long_polling_service_name">Сервис обновления сообщений</string>
<string name="notification_channel_long_polling_service_description">Уведомления сервиса обновлений сообщений</string> <string name="notification_channel_long_polling_service_description">Уведомления сервиса обновлений сообщений</string>
<string name="settings_general_show_emoji_button_title">Показывать кнопку эмоджи</string>
<string name="settings_general_show_emoji_button_summary">Показывать кнопку эмоджи на панели чата</string>
<string name="settings_features_show_time_in_action_messages_title">Показывать время в сервисных сообщениях</string>
<string name="settings_experimental_use_blur_title">Использовать размытие</string>
<string name="settings_experimental_use_blur_summary">Добавлять размытие везде, где возможно.\\nРаботает только с 12 версии Android</string>
<string name="settings_experimental_more_animations_title">Больше анимаций</string>
<string name="warning_confirmation">Подтверждение</string>
<string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string>
<string name="validation_exit_warning">Вы уверены? Процесс ввода кода-подтверждения будет отменён</string>
<string name="action_authorize">Авторизоваться</string>
<string name="no_online_friends">Никого в сети</string>
<string name="try_again">Попробовать ещё раз</string>
<string name="session_expired">Срок действия сессии истёк</string>
<string name="title_create_chat">Создать чат</string>
<string name="action_create">Создать</string>
<string name="create_chat_title">Название</string>
</resources> </resources>
+21 -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>
@@ -204,6 +204,8 @@
<string name="settings_dynamic_colors">Dynamic colors</string> <string name="settings_dynamic_colors">Dynamic colors</string>
<string name="settings_dynamic_colors_description">The colors for the app will be extracted from your home screen wallpaper</string> <string name="settings_dynamic_colors_description">The colors for the app will be extracted from your home screen wallpaper</string>
<string name="settings_appearance_use_system_font_title">Use system font</string>
<string name="settings_application_language">Application Language</string> <string name="settings_application_language">Application Language</string>
<string name="settings_application_language_value">Current: %1$s</string> <string name="settings_application_language_value">Current: %1$s</string>
@@ -235,6 +237,9 @@
<string name="settings_general_title">General</string> <string name="settings_general_title">General</string>
<string name="settings_general_contact_names_title">Use contact names</string> <string name="settings_general_contact_names_title">Use contact names</string>
<string name="settings_general_contact_names_summary">App will use available contact names for users</string> <string name="settings_general_contact_names_summary">App will use available contact names for users</string>
<string name="settings_general_show_emoji_button_title">Show emoji button</string>
<string name="settings_general_show_emoji_button_summary">Show emoji button in chat panel</string>
<string name="settings_general_enable_haptic_title">Enable haptic</string>
<string name="settings_appearance_title">Appearance</string> <string name="settings_appearance_title">Appearance</string>
<string name="settings_appearance_multiline_title">Multiline titles and messages</string> <string name="settings_appearance_multiline_title">Multiline titles and messages</string>
<string name="settings_appearance_multiline_summary">The title of the conversation and the text of the message can take up multiple lines</string> <string name="settings_appearance_multiline_summary">The title of the conversation and the text of the message can take up multiple lines</string>
@@ -242,9 +247,18 @@
<string name="settings_features_fast_text_title">Fast text</string> <string name="settings_features_fast_text_title">Fast text</string>
<string name="settings_features_long_poll_in_background_title">LongPoll in background</string> <string name="settings_features_long_poll_in_background_title">LongPoll in background</string>
<string name="settings_features_long_poll_in_background_summary">Your messages will be updating even when app is not on the screen</string> <string name="settings_features_long_poll_in_background_summary">Your messages will be updating even when app is not on the screen</string>
<string name="settings_features_show_time_in_action_messages_title">Show time in action messages</string>
<string name="settings_experimental_use_blur_title">Use blur</string>
<string name="settings_experimental_use_blur_summary">Adds blur wherever possible.\nWorks on android 12 and newer</string>
<string name="settings_experimental_more_animations_title">More animations</string>
<string name="settings_experimental_more_animations_summary">Use animations wherever possible</string>
<string name="settings_activity_title">Activity</string> <string name="settings_activity_title">Activity</string>
<string name="settings_activity_send_online_title">Send online status</string> <string name="settings_activity_send_online_title">Send online status</string>
<string name="settings_activity_send_online_summary">Online status will be sent every five minutes</string> <string name="settings_activity_send_online_summary">Online status will be sent every five minutes</string>
<string name="settings_experimental_title">Experimental - VERY unstable</string>
<string name="settings_debug_title">Debug</string> <string name="settings_debug_title">Debug</string>
<string name="background_long_poll_rationale_text">The app won\'t be able to update messages in the background without access to notifications</string> <string name="background_long_poll_rationale_text">The app won\'t be able to update messages in the background without access to notifications</string>
<string name="action_disable">Disable</string> <string name="action_disable">Disable</string>
@@ -262,6 +276,11 @@
<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>
<string name="title_create_chat">Create chat</string>
<string name="action_create">Create</string>
<string name="create_chat_title">Title</string>
</resources> </resources>
@@ -345,6 +345,13 @@ class LoginViewModelImpl(
true true
} }
is State.Error.TestError -> {
val message = stateError.message
val error = LoginError.SimpleError(message = message)
loginError.update { error }
true
}
else -> false else -> false
} }
} }
@@ -9,4 +9,5 @@ sealed class LoginError {
data object TooManyTries : LoginError() data object TooManyTries : LoginError()
data object WrongValidationCode : LoginError() data object WrongValidationCode : LoginError()
data object WrongValidationCodeFormat : LoginError() data object WrongValidationCodeFormat : LoginError()
data class SimpleError(val message: String): LoginError()
} }
@@ -50,8 +50,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginError import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
@@ -441,5 +439,14 @@ fun HandleError(
confirmText = stringResource(id = UiR.string.ok) confirmText = stringResource(id = UiR.string.ok)
) )
} }
is LoginError.SimpleError -> {
MaterialDialog(
onDismissRequest = onDismiss,
title = "Error",
text = error.message,
confirmText = stringResource(id = UiR.string.ok)
)
}
} }
} }
@@ -62,8 +62,8 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
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.chatmaterials.ChatMaterialsViewModel import dev.meloda.fast.chatmaterials.ChatMaterialsViewModel
@@ -137,7 +137,7 @@ fun ChatMaterialsScreen(
) )
} }
val titles = listOf("Photos", "Videos", "Audios", "Files", "Links") val titles = listOf("Photos", "Videos", "Audios")//, "Files", "Links")
val listState = rememberLazyListState() val listState = rememberLazyListState()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
@@ -179,7 +179,7 @@ fun ChatMaterialsScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.hazeChild( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = hazeStyle style = hazeStyle
) )
@@ -311,7 +311,7 @@ fun ChatMaterialsScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState) Modifier.hazeSource(state = hazeState)
} else { } else {
Modifier Modifier
} }
@@ -346,7 +346,7 @@ fun ChatMaterialsScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState) Modifier.hazeSource(state = hazeState)
} else { } else {
Modifier Modifier
} }
@@ -1,34 +1,36 @@
package dev.meloda.fast.conversations package dev.meloda.fast.conversations
import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.ConversationsShowOptions
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.conversations.util.asPresentation import dev.meloda.fast.conversations.util.asPresentation
import dev.meloda.fast.conversations.util.extractAvatar import dev.meloda.fast.conversations.util.extractAvatar
import dev.meloda.fast.data.State 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.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.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollParsedEvent
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.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
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
@@ -36,17 +38,14 @@ 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 {
val screenState: StateFlow<ConversationsScreenState> val screenState: StateFlow<ConversationsScreenState>
val baseError: StateFlow<BaseError?> val baseError: StateFlow<BaseError?>
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()
@@ -67,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(
@@ -78,15 +73,18 @@ class ConversationsViewModelImpl(
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
private val userSettings: UserSettings private val userSettings: UserSettings,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase
) : ConversationsViewModel, ViewModel() { ) : ConversationsViewModel, ViewModel() {
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY) override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null) override val baseError = MutableStateFlow<BaseError?>(null)
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)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
override fun onPaginationConditionsMet() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size } currentOffset.update { screenState.value.conversations.size }
@@ -106,8 +104,10 @@ class ConversationsViewModelImpl(
updatesParser.onMessageEdited(::handleEditedMessage) updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage) updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage) updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesParser.onConversationPinStateChanged(::handlePinStateChanged)
updatesParser.onInteractions(::handleInteraction) updatesParser.onInteractions(::handleInteraction)
updatesParser.onChatMajorChanged(::handleChatMajorChanged)
updatesParser.onChatMinorChanged(::handleChatMinorChanged)
updatesParser.onChatCleared(::handleChatClearing)
loadConversations() loadConversations()
} }
@@ -124,6 +124,7 @@ class ConversationsViewModelImpl(
} }
override fun onRefresh() { override fun onRefresh() {
onErrorConsumed()
loadConversations(offset = 0) loadConversations(offset = 0)
} }
@@ -166,11 +167,11 @@ class ConversationsViewModelImpl(
conversations = old.conversations.map { item -> conversations = old.conversations.map { item ->
item.copy( item.copy(
isExpanded = isExpanded =
if (item.id == conversation.id) { if (item.id == conversation.id) {
!item.isExpanded !item.isExpanded
} else { } else {
false false
}, },
options = ImmutableList.copyOf(options) options = ImmutableList.copyOf(options)
) )
} }
@@ -189,7 +190,10 @@ class ConversationsViewModelImpl(
onPinDialogDismissed() onPinDialogDismissed()
} }
override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) { override fun onOptionClicked(
conversation: UiConversation,
option: ConversationOption
) {
when (option) { when (option) {
ConversationOption.Delete -> { ConversationOption.Delete -> {
emitShowOptions { old -> emitShowOptions { old ->
@@ -226,20 +230,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(
@@ -263,17 +253,7 @@ class ConversationsViewModelImpl(
conversationsUseCase.getConversations(count = LOAD_COUNT, offset = offset) conversationsUseCase.getConversations(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 }
@@ -281,9 +261,17 @@ class ConversationsViewModelImpl(
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient &&
screenState.value.conversations.isNotEmpty() screenState.value.conversations.isNotEmpty()
imagesToPreload.setValue { val imagesToPreload =
response.mapNotNull { it.extractAvatar().extractUrl() } response.mapNotNull { it.extractAvatar().extractUrl() }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
} }
conversationsUseCase.storeConversations(response) conversationsUseCase.storeConversations(response)
val loadedConversations = response.map { val loadedConversations = response.map {
@@ -321,12 +309,48 @@ class ConversationsViewModelImpl(
} }
} }
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 deleteConversation(peerId: Int) { private fun deleteConversation(peerId: Int) {
conversationsUseCase.delete(peerId).listenValue(viewModelScope) { state -> conversationsUseCase.delete(peerId).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = {},
},
success = { success = {
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
@@ -335,11 +359,7 @@ class ConversationsViewModelImpl(
newConversations.removeAt(conversationIndex) newConversations.removeAt(conversationIndex)
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> sortConversations()
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
} }
) )
screenState.emit(screenState.value.copy(isLoading = state.isLoading())) screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
@@ -350,15 +370,13 @@ class ConversationsViewModelImpl(
conversationsUseCase.changePinState(peerId, pin) conversationsUseCase.changePinState(peerId, pin)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = {},
},
success = { success = {
handlePinStateChanged( handleChatMajorChanged(
LongPollEvent.VkConversationPinStateChangedEvent( LongPollParsedEvent.ChatMajorChanged(
peerId = peerId, peerId = peerId,
majorId = if (pin) { majorId = if (pin) {
(pinnedConversationsCount.value + 1) * 16 pinnedConversationsCount.value.plus(1) * 16
} else { } else {
0 0
} }
@@ -371,15 +389,28 @@ class ConversationsViewModelImpl(
} }
} }
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == message.peerId } newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке if (conversationIndex == null) {
// pizdets loadConversationsByIdUseCase(peerIds = listOf(message.peerId))
// TODO: 04/07/2024, Danil Nikolaev: load conversation and store info .listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = { response ->
val conversation = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConversations.add(pinnedConversationsCount.value, conversation)
conversations.update { newConversations }
sortConversations()
}
)
}
} else { } else {
val conversation = newConversations[conversationIndex] val conversation = newConversations[conversationIndex]
var newConversation = conversation.copy( var newConversation = conversation.copy(
@@ -420,13 +451,18 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
) )
} }
} }
} }
private fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message val message = event.message
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
@@ -444,86 +480,184 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
) )
} }
} }
} }
private fun handleReadIncomingMessage(event: LongPollEvent.VkMessageReadIncomingEvent) { private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return newConversations.indexOfFirstOrNull { it.id == event.peerId }
newConversations[conversationIndex] = if (conversationIndex == null) { // диалога нет в списке
newConversations[conversationIndex].copy( // pizdets
inRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
private fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
outRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
private fun handlePinStateChanged(event: LongPollEvent.VkConversationPinStateChangedEvent) {
var pinnedCount = pinnedConversationsCount.value
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
val pin = event.majorId > 0
val conversation = newConversations[conversationIndex].copy(majorId = event.majorId)
newConversations.removeAt(conversationIndex)
if (pin) {
newConversations.add(0, conversation)
} else { } else {
pinnedCount -= 1 newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
inRead = event.messageId,
unreadCount = event.unreadCount
)
newConversations.add(conversation) conversations.update { newConversations }
val pinnedSubList = newConversations.filter(VkConversation::isPinned) screenState.setValue { old ->
val unpinnedSubList = newConversations old.copy(
.filterNot(VkConversation::isPinned) conversations = newConversations.map {
.sortedByDescending { it.lastMessage?.date } it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
)
}
}
}
newConversations.clear() private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
newConversations += pinnedSubList + unpinnedSubList val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
outRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
)
}
}
}
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(majorId = event.majorId)
conversations.setValue { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
)
}
sortConversations()
}
}
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(minorId = event.minorId)
conversations.setValue { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
)
}
sortConversations()
}
}
private fun sortConversations() {
val newConversations = conversations.value.toMutableList()
val pinnedConversations = newConversations
.filter(VkConversation::isPinned)
.sortedWith { c1, c2 ->
val diff = c2.majorId - c1.majorId
if (diff == 0) {
c2.minorId - c1.minorId
} else {
diff
}
}
newConversations.removeAll(pinnedConversations)
newConversations.sortWith { c1, c2 ->
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
} }
conversations.update { newConversations } newConversations.addAll(0, pinnedConversations)
conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy(conversations = newConversations.map { it.asPresentation(resources) }) old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
)
}
}
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConversations = conversations.value.toMutableList()
val conversationIndex = newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations.removeAt(conversationIndex)
conversations.setValue { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
)
}
} }
} }
@@ -534,53 +668,60 @@ class ConversationsViewModelImpl(
val timerJob: Job val timerJob: Job
) )
private object NewInteractionException : CancellationException() private class NewInteractionException : CancellationException()
private fun handleInteraction(event: LongPollEvent.Interaction) { private fun handleInteraction(event: LongPollParsedEvent.Interaction) {
val interactionType = event.interactionType val interactionType = event.interactionType
val peerId = event.peerId val peerId = event.peerId
val userIds = event.userIds val userIds = event.userIds
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationAndIndex = val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return newConversations.findWithIndex { it.id == peerId }
newConversations[conversationAndIndex.first] = if (conversationAndIndex != null) {
conversationAndIndex.second.copy( newConversations[conversationAndIndex.first] =
interactionType = interactionType.value, conversationAndIndex.second.copy(
interactionIds = userIds interactionType = interactionType.value,
) interactionIds = userIds
)
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
) it.asPresentation(
} resources = resources,
useContactName = useContactNames
interactionsTimers[peerId]?.let { interactionJob -> )
if (interactionJob.interactionType == interactionType) { }
interactionJob.timerJob.cancel(NewInteractionException) )
} }
}
var timeoutAction: (() -> Unit)? = null interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException())
}
}
val timerJob = createTimerFlow( var timeoutAction: (() -> Unit)? = null
time = 5,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
val newInteractionJob = InteractionJob( val timerJob = createTimerFlow(
interactionType = interactionType, time = 5,
timerJob = timerJob onTimeoutAction = { timeoutAction?.invoke() }
) ).launchIn(viewModelScope)
interactionsTimers[peerId] = newInteractionJob val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
timeoutAction = { interactionsTimers[peerId] = newInteractionJob
stopInteraction(peerId, newInteractionJob)
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
}
} }
} }
@@ -600,7 +741,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
) )
} }
@@ -614,9 +760,7 @@ class ConversationsViewModelImpl(
startMessageId = startMessageId startMessageId = startMessageId
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = {},
},
success = { success = {
val newConversations = conversations.value.toMutableList() val newConversations = conversations.value.toMutableList()
val conversationIndex = val conversationIndex =
@@ -629,7 +773,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations } conversations.update { newConversations }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
conversations = newConversations.map { it.asPresentation(resources) } conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames
)
}
) )
} }
} }
@@ -1,31 +0,0 @@
package dev.meloda.fast.conversations.model
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.R as UiR
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(UiR.string.action_mark_as_read),
icon = UiImage.Resource(UiR.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(UiR.string.action_pin),
icon = UiImage.Resource(UiR.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(UiR.string.action_unpin),
icon = UiImage.Resource(UiR.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(UiR.string.action_delete),
icon = UiImage.Resource(UiR.drawable.round_delete_outline_24)
)
}
@@ -1,6 +1,8 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
@Immutable @Immutable
data class ConversationsScreenState( data class ConversationsScreenState(
@@ -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,18 +17,18 @@ 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>, onCreateChatClicked: () -> Unit,
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,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked, onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -48,11 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.getImage import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString import dev.meloda.fast.ui.util.getString
@@ -256,7 +256,7 @@ fun ConversationItem(
Row { Row {
if (conversation.interactionText != null) { if (conversation.interactionText != null) {
Text( Text(
text = conversation.interactionText, text = conversation.interactionText.orEmpty(),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -23,10 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope
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 dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -83,8 +82,7 @@ fun ConversationsList(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null) .animateItem(fadeInSpec = null, fadeOutSpec = null),
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (screenState.isPaginating) { if (screenState.isPaginating) {
@@ -107,11 +105,9 @@ fun ConversationsList(
) )
} }
} }
}
}
item { Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(bottomPadding)) }
} }
} }
} }
@@ -2,7 +2,6 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
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.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -48,13 +47,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -63,30 +59,26 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader import dev.chrisbanes.haze.hazeEffect
import coil.request.ImageRequest import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.haze
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.conversations.ConversationsViewModel import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError 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.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
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
import dev.meloda.fast.ui.util.isScrollingUp import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@Composable @Composable
@@ -94,25 +86,12 @@ fun ConversationsRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit, onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit, onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel viewModel: ConversationsViewModel
) { ) {
val context = LocalContext.current
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()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
ConversationsScreen( ConversationsScreen(
screenState = screenState, screenState = screenState,
@@ -129,10 +108,9 @@ fun ConversationsRoute(
onRefreshDropdownItemClicked = viewModel::onRefresh, onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked, onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex, setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset, setScrollOffset = viewModel::setScrollOffset
isNeedToScrollToTop = isNeedToScrollToTop,
onScrolledToTop = viewModel::onScrolledToTop
) )
HandleDialogs( HandleDialogs(
@@ -150,7 +128,7 @@ fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY, screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null, baseError: BaseError? = null,
canPaginate: Boolean = false, canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit, onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {}, onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> }, onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
@@ -158,10 +136,9 @@ fun ConversationsScreen(
onRefreshDropdownItemClicked: () -> Unit = {}, onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {}, onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> 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
@@ -175,14 +152,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)
@@ -223,10 +192,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)
) )
@@ -291,7 +260,7 @@ fun ConversationsScreen(
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()
) )
@@ -312,37 +281,13 @@ fun ConversationsScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Column { Column {
AnimatedVisibility( AnimatedVisibility(
visible = listState.isScrollingUp(), visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)), enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200)) exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) { ) {
FloatingActionButton( FloatingActionButton(onClick = onCreateChatButtonClicked) {
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
modifier = Modifier.rotate(rotation.value)
) {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24), painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button" contentDescription = "Add chat button"
@@ -355,12 +300,24 @@ fun ConversationsScreen(
} }
) { 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.conversations.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.conversations.isEmpty() -> FullScreenLoader()
@@ -394,7 +351,7 @@ fun ConversationsScreen(
state = listState, state = listState,
maxLines = maxLines, maxLines = maxLines,
modifier = if (currentTheme.enableBlur) { modifier = if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState) Modifier.hazeSource(state = hazeState)
} else { } else {
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
@@ -402,6 +359,13 @@ fun ConversationsScreen(
padding = padding, padding = padding,
onPhotoClicked = onConversationPhotoClicked onPhotoClicked = onConversationPhotoClicked
) )
if (screenState.conversations.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
} }
} }
} }
@@ -426,9 +390,7 @@ fun HandleDialogs(
) )
} }
if (showOptions.showPinDialog != null) { showOptions.showPinDialog?.let { conversation ->
val conversation = showOptions.showPinDialog
MaterialDialog( MaterialDialog(
onDismissRequest = viewModel::onPinDialogDismissed, onDismissRequest = viewModel::onPinDialogDismissed,
title = stringResource( title = stringResource(
@@ -14,8 +14,6 @@ import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.util.TimeUtils import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.conversations.model.ActionState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
@@ -24,6 +22,8 @@ import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
@@ -33,7 +33,7 @@ import dev.meloda.fast.ui.R as UiR
fun VkConversation.asPresentation( fun VkConversation.asPresentation(
resources: Resources, resources: Resources,
useContactName: Boolean = false useContactName: Boolean
): UiConversation = UiConversation( ): UiConversation = UiConversation(
id = id, id = id,
lastMessageId = lastMessageId, lastMessageId = lastMessageId,
+1
View File
@@ -0,0 +1 @@
/build
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.fast.android.feature)
alias(libs.plugins.fast.android.library.compose)
}
android {
namespace = "dev.meloda.fast.createchat"
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.bundles.nanokt)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
@@ -0,0 +1,243 @@
package dev.meloda.fast.conversations
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.api.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
interface CreateChatViewModel {
val screenState: StateFlow<CreateChatScreenState>
val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
val isChatCreated: StateFlow<Int?>
fun onPaginationConditionsMet()
fun onRefresh()
fun onErrorConsumed()
fun toggleFriendSelection(userId: Int)
fun onTitleTextInputChanged(newTitle: String)
fun onCreateChatButtonClicked()
fun onNavigatedBack()
}
class CreateChatViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val messagesUseCase: MessagesUseCase,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
private val userSettings: UserSettings
) : CreateChatViewModel, ViewModel() {
override val screenState = MutableStateFlow(CreateChatScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override val isChatCreated = MutableStateFlow<Int?>(null)
private val useContactNames: Boolean = userSettings.useContactNames.value
init {
loadFriends()
}
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size }
loadFriends()
}
override fun onRefresh() {
onErrorConsumed()
loadFriends(offset = 0)
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
override fun toggleFriendSelection(userId: Int) {
val newSelectionList = screenState.value.selectedFriendsIds.toMutableList()
if (newSelectionList.contains(userId)) {
newSelectionList.remove(userId)
} else {
newSelectionList.add(userId)
}
screenState.setValue { old ->
old.copy(selectedFriendsIds = newSelectionList)
}
}
override fun onTitleTextInputChanged(newTitle: String) {
screenState.setValue { old -> old.copy(chatTitle = newTitle) }
}
override fun onCreateChatButtonClicked() {
createChat()
}
override fun onNavigatedBack() {
viewModelScope.launch(Dispatchers.Main) {
isChatCreated.emit(null)
}
}
private fun loadFriends(
offset: Int = currentOffset.value
) {
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.friends.isNotEmpty()
val imagesToPreload =
response.mapNotNull { it.photo100.takeIf { !it.isNullOrEmpty() } }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(useContactNames)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
screenState.setValue {
newState.copy(friends = loadedFriends)
}
} else {
screenState.setValue {
newState.copy(
friends = newState.friends.plus(loadedFriends)
)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun createChat() {
viewModelScope.launch {
val title = screenState.value.chatTitle.takeUnless(String::isBlank)
val accountAsFriend =
getLocalUserByIdUseCase.proceed(UserConfig.userId)?.asPresentation(useContactNames)
val accountList = accountAsFriend?.let(::listOf) ?: emptyList()
val selectedFriends = screenState.value.selectedFriendsIds
.takeIf { it.isNotEmpty() }
?.mapNotNull { userId -> screenState.value.friends.find { it.userId == userId } }
messagesUseCase.createChat(
userIds = selectedFriends?.map { it.userId },
title = title
?: (accountList + selectedFriends.orEmpty()).joinToString(transform = UiFriend::firstName)
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
withContext(Dispatchers.Main) {
isChatCreated.emit(2_000_000_000 + response)
}
}
)
}
}
}
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
}
}
companion object {
const val LOAD_COUNT = 30
}
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.CreateChatViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val createChatModule = module {
viewModelOf(::CreateChatViewModelImpl)
}
@@ -0,0 +1,25 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable
data class CreateChatScreenState(
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val friends: List<UiFriend>,
val selectedFriendsIds: List<Int>,
val chatTitle: String
) {
companion object {
val EMPTY: CreateChatScreenState = CreateChatScreenState(
isLoading = true,
isPaginating = false,
isPaginationExhausted = false,
friends = emptyList(),
selectedFriendsIds = emptyList(),
chatTitle = ""
)
}
}
@@ -0,0 +1,36 @@
package dev.meloda.fast.conversations.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.CreateChatViewModelImpl
import dev.meloda.fast.conversations.presentation.CreateChatRoute
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable
@Serializable
object CreateChat
fun NavGraphBuilder.createChatScreen(
onChatCreated: (Int) -> Unit,
navController: NavController,
) {
composable<CreateChat> {
val viewModel: CreateChatViewModel =
it.sharedViewModel<CreateChatViewModelImpl>(navController = navController)
CreateChatRoute(
onError = {
},
onBack = navController::popBackStack,
onChatCreated = onChatCreated,
viewModel = viewModel
)
}
}
fun NavController.navigateToCreateChat() {
this.navigate(CreateChat)
}
@@ -0,0 +1,110 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable
fun CreateChatItem(
modifier: Modifier = Modifier,
friend: UiFriend,
maxLines: Int,
isSelected: Boolean,
onItemClicked: (Int) -> Unit
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onItemClicked(friend.userId) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(16.dp))
val friendAvatar = friend.avatar?.extractUrl()
Box(modifier = Modifier.size(56.dp)) {
if (friendAvatar == null) {
Image(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
painter = painterResource(id = R.drawable.ic_account_circle_cut),
contentDescription = "Avatar",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
} else {
AsyncImage(
model = friendAvatar,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
)
}
if (friend.onlineStatus.isOnline()) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(18.dp)
.background(MaterialTheme.colorScheme.background)
.padding(2.dp)
.align(Alignment.BottomEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(MaterialTheme.colorScheme.primary)
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = friend.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(16.dp))
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClicked(friend.userId) },
)
Spacer(modifier = Modifier.width(16.dp))
}
}
@@ -0,0 +1,101 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CreateChatList(
screenState: CreateChatScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
padding: PaddingValues,
onItemClicked: (Int) -> Unit,
onTitleTextInputChanged: (String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
LazyColumn(
modifier = modifier,
state = state
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(
items = screenState.friends,
key = UiFriend::userId,
) { friend ->
CreateChatItem(
maxLines = maxLines,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
friend = friend,
isSelected = screenState.selectedFriendsIds.contains(friend.userId),
onItemClicked = onItemClicked
)
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
state.scrollToItem(14)
state.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
@@ -0,0 +1,342 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp
import dev.meloda.fast.ui.R as UiR
@Composable
fun CreateChatRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatCreated: (Int) -> Unit,
viewModel: CreateChatViewModel
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isChatCreated by viewModel.isChatCreated.collectAsStateWithLifecycle()
LaunchedEffect(isChatCreated) {
if (isChatCreated != null) {
onChatCreated(isChatCreated ?: -1)
viewModel.onNavigatedBack()
}
}
CreateChatScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onBack = onBack,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onItemClicked = viewModel::toggleFriendSelection,
onTitleTextInputChanged = viewModel::onTitleTextInputChanged
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun CreateChatScreen(
screenState: CreateChatScreenState = CreateChatScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onBack: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onItemClicked: (Int) -> Unit = {},
onTitleTextInputChanged: (String) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) {
mutableIntStateOf(if (currentTheme.enableMultiline) 2 else 1)
}
val listState = rememberLazyListState()
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
val hazeState = LocalHazeState.current
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
toolbarContainerColor.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
)
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
title = {
Text(
text = stringResource(
id = if (screenState.isLoading) UiR.string.title_loading
else UiR.string.title_create_chat
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = 0f
)
),
modifier = Modifier.fillMaxWidth(),
)
var isTextFieldFocused by remember {
mutableStateOf(false)
}
val borderWidth by animateDpAsState(if (isTextFieldFocused) 1.5.dp else 0.dp)
val borderColor by animateColorAsState(
if (isTextFieldFocused) MaterialTheme.colorScheme.primary
else Color.Transparent
)
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(
borderWidth,
borderColor,
RoundedCornerShape(16.dp)
)
.clip(RoundedCornerShape(16.dp))
.onFocusChanged { isTextFieldFocused = it.hasFocus },
value = screenState.chatTitle,
onValueChange = onTitleTextInputChanged,
label = { Text(text = stringResource(UiR.string.create_chat_title)) },
placeholder = { Text(text = stringResource(UiR.string.create_chat_title)) },
singleLine = true,
colors = TextFieldDefaults.colors(
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
)
)
Spacer(Modifier.height(16.dp))
}
},
floatingActionButton = {
if (baseError == null) {
Column(
modifier = Modifier
.imePadding()
.navigationBarsPadding()
) {
ExtendedFloatingActionButton(
onClick = onCreateChatButtonClicked,
expanded = listState.isScrollingUp(),
text = { Text(text = stringResource(UiR.string.action_create)) },
icon = {
Icon(
imageVector = Icons.Rounded.Done,
contentDescription = null
)
}
)
}
}
}
) { padding ->
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(UiR.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
}
}
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
CreateChatList(
screenState = screenState,
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
padding = padding,
onItemClicked = onItemClicked,
onTitleTextInputChanged = onTitleTextInputChanged
)
if (screenState.friends.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
}
@@ -9,8 +9,8 @@ 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.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.util.asPresentation
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.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
@@ -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
@@ -1,6 +1,7 @@
package dev.meloda.fast.friends.model package dev.meloda.fast.friends.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable @Immutable
data class FriendsScreenState( data class FriendsScreenState(
@@ -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
@@ -23,15 +27,16 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable @Composable
fun FriendItem( 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))
} }
} }
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -23,8 +22,7 @@ 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 dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.model.UiFriend import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -38,6 +36,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) {
@@ -49,8 +48,6 @@ fun FriendsList(
val friends = uiFriends.toList() val friends = uiFriends.toList()
val bottomPadding = LocalBottomPadding.current
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState state = listState
@@ -67,7 +64,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))
@@ -77,8 +75,7 @@ fun FriendsList(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null) .animateItem(fadeInSpec = null, fadeOutSpec = null),
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (screenState.isPaginating) { if (screenState.isPaginating) {
@@ -101,11 +98,9 @@ fun FriendsList(
) )
} }
} }
}
}
item { Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(bottomPadding)) }
} }
} }
} }
@@ -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 :("
) )
} }
} }
@@ -24,13 +24,15 @@ 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
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.navigation.MessagesHistory import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.messageshistory.util.asPresentation import dev.meloda.fast.messageshistory.util.asPresentation
import dev.meloda.fast.messageshistory.util.extractAvatar import dev.meloda.fast.messageshistory.util.extractAvatar
import dev.meloda.fast.messageshistory.util.extractTitle import dev.meloda.fast.messageshistory.util.extractTitle
import dev.meloda.fast.messageshistory.util.findMessageById
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -156,11 +158,13 @@ class MessagesHistoryViewModelImpl(
loadMessagesHistory() loadMessagesHistory()
} }
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message") Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.conversationId) return if (message.peerId != screenState.value.conversationId) return
if (screenState.value.messages.findMessageById(message.id) != null) return
val randomIds = messages.value.map(VkMessage::randomId) val randomIds = messages.value.map(VkMessage::randomId)
if (message.randomId != 0 && message.randomId in randomIds) return if (message.randomId != 0 && message.randomId in randomIds) return
@@ -174,29 +178,29 @@ class MessagesHistoryViewModelImpl(
val newMessage = message.asPresentation( val newMessage = message.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = prevMessage, prevMessage = prevMessage,
nextMessage = null, nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation,
) )
newMessages.add(0, newMessage) newMessages.add(0, newMessage)
prevMessage?.let { prev -> prevMessage?.let { prev ->
newMessages[1] = prev.asPresentation( newMessages[1] = prev.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = prevMessage, prevMessage = prevMessage,
nextMessage = messages.value.first(), nextMessage = messages.value.first(),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
) )
} }
screenState.setValue { old -> old.copy(messages = newMessages) } screenState.setValue { old -> old.copy(messages = newMessages) }
} }
private fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message val message = event.message
if (message.peerId != screenState.value.conversationId) return if (message.peerId != screenState.value.conversationId) return
@@ -205,11 +209,11 @@ class MessagesHistoryViewModelImpl(
?.let { index -> ?.let { index ->
val newMessage = message.asPresentation( val newMessage = message.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = messages.value.getOrNull(index + 1), prevMessage = messages.value.getOrNull(index + 1),
nextMessage = messages.value.getOrNull(index - 1), nextMessage = messages.value.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
) )
val newMessages = screenState.value.messages.toMutableList() val newMessages = screenState.value.messages.toMutableList()
@@ -219,12 +223,72 @@ class MessagesHistoryViewModelImpl(
} }
} }
private fun handleReadIncomingEvent(event: LongPollEvent.VkMessageReadIncomingEvent) { private fun handleReadIncomingEvent(event: LongPollParsedEvent.IncomingMessageRead) {
if (event.peerId != screenState.value.conversationId) return
val messages = messages.value
val messageIndex =
messages.indexOfFirstOrNull { it.id == event.messageId }
if (messageIndex == null) { // диалога нет в списке
// pizdets
} else {
val newConversation = screenState.value.conversation.copy(
inRead = event.messageId
)
val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = newConversation
)
}
screenState.setValue { old ->
old.copy(
conversation = newConversation,
messages = uiMessages,
)
}
}
} }
private fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) { private fun handleReadOutgoingEvent(event: LongPollParsedEvent.OutgoingMessageRead) {
if (event.peerId != screenState.value.conversationId) return
val messages = messages.value
val messageIndex =
messages.indexOfFirstOrNull { it.id == event.messageId }
if (messageIndex == null) { // диалога нет в списке
// pizdets
} else {
val newConversation = screenState.value.conversation.copy(
outRead = event.messageId
)
val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = newConversation
)
}
screenState.setValue { old ->
old.copy(
conversation = newConversation,
messages = uiMessages,
)
}
}
} }
private fun loadMessagesHistory(offset: Int = currentOffset.value) { private fun loadMessagesHistory(offset: Int = currentOffset.value) {
@@ -236,9 +300,7 @@ class MessagesHistoryViewModelImpl(
offset = offset, offset = offset,
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error -> },
},
success = { response -> success = { response ->
val messages = response.messages val messages = response.messages
val fullMessages = if (offset == 0) { val fullMessages = if (offset == 0) {
@@ -256,16 +318,6 @@ class MessagesHistoryViewModelImpl(
messagesUseCase.storeMessages(messages) messagesUseCase.storeMessages(messages)
conversationsUseCase.storeConversations(conversations) conversationsUseCase.storeConversations(conversations)
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
)
}
val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT
@@ -278,15 +330,28 @@ class MessagesHistoryViewModelImpl(
conversations conversations
.firstOrNull { it.id == screenState.value.conversationId } .firstOrNull { it.id == screenState.value.conversationId }
?.let { conversation -> ?.let { conversation ->
screenState.setValue { old -> old.copy(conversation = conversation) }
newState = newState.copy( newState = newState.copy(
title = conversation.extractTitle( title = conversation.extractTitle(
useContactName = AppSettings.General.useContactNames, useContactName = AppSettings.General.useContactNames,
resources = resourceProvider.resources resources = resourceProvider.resources
), ),
avatar = conversation.extractAvatar() avatar = conversation.extractAvatar(),
conversation = conversation
) )
} }
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
}
this.messages.emit(fullMessages) this.messages.emit(fullMessages)
screenState.setValue { newState.copy(messages = loadedMessages) } screenState.setValue { newState.copy(messages = loadedMessages) }
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
@@ -347,18 +412,14 @@ class MessagesHistoryViewModelImpl(
val newMessages = screenState.value.messages.toMutableList() val newMessages = screenState.value.messages.toMutableList()
val newUiMessage = newMessage.asPresentation( val newUiMessage = newMessage.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = messages.value.firstOrNull(), prevMessage = messages.value.firstOrNull(),
nextMessage = null, nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
) )
newMessages.add(0, newUiMessage) newMessages.add(0, newUiMessage)
messages.setValue { old ->
listOf(newMessage).plus(old)
}
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
message = TextFieldValue(), message = TextFieldValue(),
@@ -377,19 +438,37 @@ class MessagesHistoryViewModelImpl(
state.processState( state.processState(
error = { error -> error = { error ->
sendingMessages -= newMessage sendingMessages -= newMessage
},
success = { messageId ->
sendingMessages += newMessage
val messages = screenState.value.messages.toMutableList() val uiMessages = screenState.value.messages.toMutableList()
messages.indexOfOrNull(newUiMessage)?.let { index -> uiMessages.indexOfOrNull(newUiMessage)?.let { index ->
(messages[index] as? UiItem.Message)?.let { message -> (uiMessages[index] as? UiItem.Message)?.let { message ->
messages[index] = message.copy(id = messageId) uiMessages[index] = message.copy(sendingStatus = SendingStatus.FAILED)
} }
} }
screenState.setValue { old -> old.copy(messages = messages) } screenState.setValue { old -> old.copy(messages = uiMessages) }
},
success = { messageId ->
sendingMessages -= newMessage
val uiMessages = screenState.value.messages.toMutableList()
messages.setValue { old ->
listOf(newMessage.copy(id = messageId)).plus(old)
}
uiMessages.indexOfOrNull(newUiMessage)?.let { index ->
(uiMessages[index] as? UiItem.Message)?.let { message ->
uiMessages[index] = message
.copy(
id = messageId,
sendingStatus = SendingStatus.SENT
)
.copy(isRead = newMessage.isRead(screenState.value.conversation))
}
}
screenState.setValue { old -> old.copy(messages = uiMessages) }
} }
) )
} }
@@ -508,11 +587,11 @@ class MessagesHistoryViewModelImpl(
val uiMessages = messages.mapIndexed { index, item -> val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation( item.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = messages.getOrNull(index + 1), prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1), nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = show showTimeInActionMessages = show,
conversation = screenState.value.conversation
) )
} }
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
@Immutable @Immutable
data class MessagesHistoryScreenState( data class MessagesHistoryScreenState(
@@ -18,7 +19,8 @@ data class MessagesHistoryScreenState(
val isPaginating: Boolean, val isPaginating: Boolean,
val isPaginationExhausted: Boolean, val isPaginationExhausted: Boolean,
val actionMode: ActionMode, val actionMode: ActionMode,
val chatImageUrl: String? val chatImageUrl: String?,
val conversation: VkConversation
) { ) {
companion object { companion object {
@@ -34,7 +36,8 @@ data class MessagesHistoryScreenState(
isPaginating = false, isPaginating = false,
isPaginationExhausted = false, isPaginationExhausted = false,
actionMode = ActionMode.Record, actionMode = ActionMode.Record,
chatImageUrl = null chatImageUrl = null,
conversation = VkConversation.EMPTY
) )
} }
} }
@@ -0,0 +1,5 @@
package dev.meloda.fast.messageshistory.model
enum class SendingStatus {
SENDING, SENT, FAILED
}
@@ -22,7 +22,9 @@ sealed class UiItem(
val showAvatar: Boolean, val showAvatar: Boolean,
val showName: Boolean, val showName: Boolean,
val avatar: UiImage, val avatar: UiImage,
val isEdited: Boolean val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT
) : UiItem(id, conversationMessageId) ) : UiItem(id, conversationMessageId)
data class ActionMessage( data class ActionMessage(
@@ -32,4 +34,3 @@ sealed class UiItem(
val actionCmId: Int? val actionCmId: Int?
) : UiItem(id, conversationMessageId) ) : UiItem(id, conversationMessageId)
} }
@@ -31,6 +31,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun IncomingMessageBubble( fun IncomingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
animate: Boolean
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -44,12 +45,12 @@ fun IncomingMessageBubble(
if (message.isInChat) { if (message.isInChat) {
Image( Image(
painter = painter =
message.avatar.extractUrl()?.let { url -> message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter( rememberAsyncImagePainter(
model = url, model = url,
imageLoader = context.imageLoader imageLoader = context.imageLoader
) )
} ?: painterResource(id = message.avatar.extractResId()), } ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(bottom = 6.dp) .padding(bottom = 6.dp)
@@ -80,6 +81,9 @@ fun IncomingMessageBubble(
isOut = false, isOut = false,
date = message.date, date = message.date,
edited = message.isEdited, edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
) )
} }
} }
@@ -1,19 +1,35 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun MessageBubble( fun MessageBubble(
@@ -22,6 +38,9 @@ fun MessageBubble(
isOut: Boolean, isOut: Boolean,
date: String?, date: String?,
edited: Boolean, edited: Boolean,
animate: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus
) { ) {
val backgroundColor = if (!isOut) { val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
@@ -45,44 +64,70 @@ fun MessageBubble(
vertical = 6.dp vertical = 6.dp
) )
) { ) {
val minDateContainerWidth = remember(edited, isOut) {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
mainPart + readIndicatorPart
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
label = "dateContainerWidth"
)
if (text != null) { if (text != null) {
Text( Text(
text = text, text = text,
modifier = Modifier modifier = Modifier
.padding(2.dp) .padding(2.dp)
.align(Alignment.Center) .align(Alignment.Center)
.animateContentSize(), .padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (animate) Modifier.animateContentSize() else Modifier),
color = textColor color = textColor
) )
} }
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth)
) {
if (edited) {
Icon(
imageVector = Icons.Rounded.Create,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall,
)
Spacer(modifier = Modifier.width(4.dp))
// val dateContainerWidth by animateDpAsState( if (isOut) {
// targetValue = if (edited) 50.dp else 30.dp, Icon(
// label = "dateContainerWidth" modifier = Modifier.size(14.dp),
// ) painter = painterResource(
when (sendingStatus) {
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
SendingStatus.SENT -> {
if (isRead) UiR.drawable.round_done_all_24
else UiR.drawable.ic_round_done_24
}
// AnimatedVisibility( SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
// date != null, }
// modifier = Modifier ),
// .width(dateContainerWidth) tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
// .align(Alignment.BottomEnd) else LocalContentColor.current,
// ) { contentDescription = null
// Row(modifier = Modifier.fillMaxWidth()) { )
// if (edited) { }
// Icon( }
// imageVector = Icons.Rounded.Create,
// contentDescription = null,
// modifier = Modifier.size(14.dp)
// )
// Spacer(modifier = Modifier.width(4.dp))
// }
// Text(
// text = date.orEmpty(),
// style = MaterialTheme.typography.labelSmall
// )
// Spacer(modifier = Modifier.width(2.dp))
// }
// }
} }
} }
@@ -159,7 +159,7 @@ fun MessagesHistoryScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val paginationConditionMet by remember { val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf { derivedStateOf {
canPaginate && canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
@@ -282,6 +282,7 @@ fun MessagesHistoryScreen(
// TODO: 11/07/2024, Danil Nikolaev: to VM // TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat)
onChatMaterialsDropdownItemClicked( onChatMaterialsDropdownItemClicked(
screenState.conversationId, screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId screenState.messages.firstMessage().conversationMessageId
@@ -90,26 +90,28 @@ fun MessagesList(
if (item.isOut) { if (item.isOut) {
OutgoingMessageBubble( OutgoingMessageBubble(
modifier = modifier =
Modifier.then( Modifier.then(
if (enableAnimations) Modifier.animateItem( if (enableAnimations) Modifier.animateItem(
fadeInSpec = null, fadeInSpec = null,
fadeOutSpec = null fadeOutSpec = null
) )
else Modifier else Modifier
), ),
message = item, message = item,
animate = enableAnimations
) )
} else { } else {
IncomingMessageBubble( IncomingMessageBubble(
modifier = modifier =
Modifier.then( Modifier.then(
if (enableAnimations) Modifier.animateItem( if (enableAnimations) Modifier.animateItem(
fadeInSpec = null, fadeInSpec = null,
fadeOutSpec = null fadeOutSpec = null
) )
else Modifier else Modifier
), ),
message = item, message = item,
animate = enableAnimations
) )
} }
} }
@@ -128,16 +130,17 @@ fun MessagesList(
} }
} }
Spacer( Spacer(Modifier.height(8.dp))
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.height(64.dp) .height(64.dp)
.fillMaxWidth() .fillMaxWidth()
) )
Spacer(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
} }
} }
} }
@@ -3,12 +3,8 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
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
@@ -20,6 +16,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun OutgoingMessageBubble( fun OutgoingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
animate: Boolean
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -37,18 +34,12 @@ fun OutgoingMessageBubble(
modifier = Modifier, modifier = Modifier,
text = message.text.orDots(), text = message.text.orDots(),
isOut = true, isOut = true,
date = null, date = message.date,
edited = message.isEdited, edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
) )
if (message.showDate) {
Spacer(modifier = Modifier.height(2.dp))
Text(
modifier = Modifier.padding(end = 12.dp),
text = message.date,
style = MaterialTheme.typography.labelSmall
)
}
} }
} }
} }
@@ -4,11 +4,13 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first() fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
fun List<UiItem>.indexOfMessageById(messageId: Int): Int = fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
indexOfFirst { it.id == messageId } indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message = fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
first { it.id == messageId } as UiItem.Message firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int = fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
indexOfFirst { it.cmId == cmId } indexOfFirst { it.cmId == cmId }
@@ -12,6 +12,7 @@ import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
@@ -90,8 +91,8 @@ fun VkConversation.extractTitle(
}.parseString(resources).orDots() }.parseString(resources).orDots()
fun VkMessage.asPresentation( fun VkMessage.asPresentation(
conversation: VkConversation,
resourceProvider: ResourceProvider, resourceProvider: ResourceProvider,
showDate: Boolean,
showName: Boolean, showName: Boolean,
prevMessage: VkMessage?, prevMessage: VkMessage?,
nextMessage: VkMessage?, nextMessage: VkMessage?,
@@ -118,15 +119,19 @@ fun VkMessage.asPresentation(
randomId = randomId, randomId = randomId,
isInChat = isPeerChat(), isInChat = isPeerChat(),
name = extractTitle(), name = extractTitle(),
showDate = showDate, showDate = true,
showAvatar = extractShowAvatar(nextMessage), showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage), showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(), avatar = extractAvatar(),
isEdited = updateTime != null isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
}
) )
} }
fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean { fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean {
if (isOut) return false if (isOut) return false
return nextMessage == null || nextMessage.fromId != fromId return nextMessage == null || nextMessage.fromId != fromId

Some files were not shown because too many files have changed in this diff Show More