diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 877c0e80..4f27a3fe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { implementation(projects.feature.friends) implementation(projects.feature.profile) implementation(projects.feature.photoviewer) + implementation(projects.feature.createchat) implementation(projects.core.common) implementation(projects.core.ui) diff --git a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt index d17330bd..01d0feae 100644 --- a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt +++ b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt @@ -16,6 +16,7 @@ import dev.meloda.fast.common.provider.Provider import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProviderImpl 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.friends.di.friendsModule import dev.meloda.fast.languagepicker.di.languagePickerModule @@ -46,7 +47,8 @@ val applicationModule = module { longPollModule, friendsModule, profileModule, - chatMaterialsModule + chatMaterialsModule, + createChatModule ) // TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors diff --git a/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt b/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt index 4f20efcd..f4ab6d59 100644 --- a/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt +++ b/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt @@ -24,6 +24,8 @@ fun NavGraphBuilder.mainScreen( onSettingsButtonClicked: () -> Unit, onConversationClicked: (conversationId: Int) -> Unit, onPhotoClicked: (url: String) -> Unit, + onMessageClicked: (userId: Int) -> Unit, + onCreateChatClicked: () -> Unit, viewModel: MainViewModel ) { val navigationItems = ImmutableList.of( @@ -54,6 +56,8 @@ fun NavGraphBuilder.mainScreen( onSettingsButtonClicked = onSettingsButtonClicked, onConversationItemClicked = onConversationClicked, onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked, + onCreateChatClicked = onCreateChatClicked, viewModel = viewModel ) } diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt index f44085b2..bf93b82f 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt @@ -33,7 +33,7 @@ import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import coil.compose.SubcomposeAsyncImage 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.HazeMaterials 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.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow @OptIn(ExperimentalHazeMaterialsApi::class) @Composable @@ -58,6 +56,8 @@ fun MainScreen( onSettingsButtonClicked: () -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {}, + onMessageClicked: (userId: Int) -> Unit = {}, + onCreateChatClicked: () -> Unit = {}, viewModel: MainViewModel ) { val currentTheme = LocalThemeConfig.current @@ -70,21 +70,13 @@ fun MainScreen( mutableIntStateOf(1) } - val sharedFlow = remember { - MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - } - Scaffold( bottomBar = { NavigationBar( modifier = Modifier .then( if (currentTheme.enableBlur) { - Modifier.hazeChild( + Modifier.hazeEffect( state = hazeState, style = HazeMaterials.thick() ) @@ -108,8 +100,6 @@ fun MainScreen( inclusive = true } } - } else { - sharedFlow.tryEmit(index) } }, icon = { @@ -176,13 +166,14 @@ fun MainScreen( friendsScreen( onError = onError, navController = navController, - onPhotoClicked = onPhotoClicked + onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked ) conversationsScreen( onError = onError, onConversationItemClicked = onConversationItemClicked, onPhotoClicked = onPhotoClicked, - scrollToTopFlow = sharedFlow, + onCreateChatClicked = onCreateChatClicked, navController = navController, ) profileScreen( diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt index 4678a203..b796f63d 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt @@ -25,6 +25,8 @@ import dev.meloda.fast.auth.authNavGraph import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen 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.navigateToLanguagePicker import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen @@ -124,6 +126,8 @@ fun RootScreen( onSettingsButtonClicked = navController::navigateToSettings, onConversationClicked = navController::navigateToMessagesHistory, onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }, + onMessageClicked = navController::navigateToMessagesHistory, + onCreateChatClicked = navController::navigateToCreateChat, viewModel = viewModel ) @@ -136,6 +140,13 @@ fun RootScreen( onBack = navController::navigateUp, onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } ) + createChatScreen( + onChatCreated = { conversationId -> + navController.popBackStack() + navController.navigateToMessagesHistory(conversationId) + }, + navController = navController + ) settingsScreen( onBack = navController::navigateUp, diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt index f1721fab..a9713a38 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt @@ -1,10 +1,10 @@ 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.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.network.RestApiErrorDomain -import com.slack.eithernet.ApiResult interface MessagesRepository { @@ -41,6 +41,11 @@ interface MessagesRepository { conversationMessageId: Int ): ApiResult, RestApiErrorDomain> + suspend fun createChat( + userIds: List?, + title: String? + ): ApiResult + suspend fun storeMessages(messages: List) // suspend fun markAsImportant( diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt index ded9b1ff..0dafa7fe 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt @@ -1,5 +1,6 @@ package dev.meloda.fast.data.api.messages +import com.slack.eithernet.ApiResult import dev.meloda.fast.common.VkConstants import dev.meloda.fast.data.VkGroupsMap 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.VkMessage 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.MessagesGetHistoryAttachmentsRequest 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.mapApiResult import dev.meloda.fast.network.service.messages.MessagesService -import com.slack.eithernet.ApiResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -198,6 +199,23 @@ class MessagesRepositoryImpl( ) } + override suspend fun createChat( + userIds: List?, + title: String? + ): ApiResult = 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) { messageDao.insertAll(messages.map(VkMessage::asEntity)) } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt index bf61c2e7..37d322dc 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/GetLocalUserByIdUseCase.kt @@ -20,4 +20,8 @@ class GetLocalUserByIdUseCase(private val repository: UsersRepository) { emit(newState) } + + suspend fun proceed(userId: Int): VkUser? { + return repository.getLocalUsers(userIds = listOf(userId)).singleOrNull() + } } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt index 4a5ab80f..65037ee5 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt @@ -41,15 +41,9 @@ class LongPollUpdatesParser( fun parseNextUpdate(event: List) { val eventId = event.first().asInt() - val eventType: ApiEvent = try { - ApiEvent.parse(eventId) - } catch (e: Exception) { - e.printStackTrace() - Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event") - return - } + when (val eventType = ApiEvent.parseOrNull(eventId)) { + null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event") - when (eventType) { ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event) diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt index 84e533c7..7275c87d 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt @@ -42,6 +42,11 @@ interface MessagesUseCase { conversationMessageId: Int ): Flow>> + fun createChat( + userIds: List?, + title: String? + ): Flow> + suspend fun storeMessage(message: VkMessage) suspend fun storeMessages(messages: List) } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt index 9239300e..3c6c9f52 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt @@ -100,6 +100,14 @@ class MessagesUseCaseImpl( emit(newState) } + override fun createChat(userIds: List?, title: String?): Flow> = flow { + emit(State.Loading) + + val newState = repository.createChat(userIds, title).mapToState() + + emit(newState) + } + override suspend fun storeMessage(message: VkMessage) { repository.storeMessages(listOf(message)) } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/util/FriendMapper.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/util/FriendMapper.kt similarity index 70% rename from feature/friends/src/main/kotlin/dev/meloda/fast/friends/util/FriendMapper.kt rename to core/domain/src/main/kotlin/dev/meloda/fast/domain/util/FriendMapper.kt index 4c4d3fb4..0c37194d 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/util/FriendMapper.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/util/FriendMapper.kt @@ -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.data.VkMemoryCache -import dev.meloda.fast.friends.model.UiFriend import dev.meloda.fast.model.api.domain.VkUser +import dev.meloda.fast.ui.model.api.UiFriend fun VkUser.asPresentation( useContactNames: Boolean = false @@ -16,5 +16,7 @@ fun VkUser.asPresentation( fullName }, onlineStatus = onlineStatus, - photo400Orig = photo400Orig?.let(UiImage::Url) + photo400Orig = photo400Orig?.let(UiImage::Url), + firstName = firstName, + lastName = lastName ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt index 2fce295e..74067e29 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/ApiEvent.kt @@ -18,5 +18,6 @@ enum class ApiEvent(val value: Int) { companion object { fun parse(value: Int) = entries.first { it.value == value } + fun parseOrNull(value: Int) = entries.firstOrNull { it.value == value } } } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt index 5eb47897..c54a6ad7 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkConversation.kt @@ -38,6 +38,41 @@ data class VkConversation( fun isPinned(): Boolean = majorId > 0 fun isInUnread() = inRead - (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( diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt index 9e86f436..6829b0ce 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt @@ -38,12 +38,11 @@ data class VkMessage( fun isGroup() = fromId < 0 - fun isRead(conversation: VkConversation) = - if (isOut) { - conversation.outRead - id >= 0 - } else { - conversation.inRead - id >= 0 - } + fun isRead(conversation: VkConversation): Boolean = when { + id <= 0 -> false + isOut -> conversation.outRead - id >= 0 + else -> conversation.inRead - id >= 0 + } fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty() diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt index 00ab745f..6bc6f569 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt @@ -267,3 +267,14 @@ data class MessagesGetHistoryAttachmentsRequest( fields?.let { this["fields"] = it } } } + +data class MessagesCreateChatRequest( + val userIds: List?, + val title: String? +) { + + val map = mutableMapOf().apply { + userIds?.let { this["user_ids"] = it.joinToString(",") } + title?.let { this["title"] = it } + } +} diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt index cd7df491..672295f5 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/responses/MessagesResponse.kt @@ -1,5 +1,7 @@ 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.VkChatMemberData 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.VkMessageData import dev.meloda.fast.model.api.data.VkUserData -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class MessagesGetHistoryResponse( @@ -44,3 +44,9 @@ data class MessagesGetHistoryAttachmentsResponse( @Json(name = "groups") val groups: List?, @Json(name = "contacts") val contacts: List? ) + +@JsonClass(generateAdapter = true) +data class MessagesCreateChatResponse( + @Json(name = "chat_id") val chatId: Int, + @Json(name = "peer_ids") val peerIds: List +) diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt index 3168077d..af40f0f3 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt @@ -43,6 +43,12 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter converter.fromJson(successType, string) }.fold( onSuccess = { successModel -> + if (successModel is ApiResponse<*>) { + if (successModel.error != null) { + throw ApiException(successModel.error) + } + } + return successModel }, onFailure = { failure -> diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/VkErrorCode.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/VkErrorCode.kt index b15d2734..7273e354 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/VkErrorCode.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/VkErrorCode.kt @@ -42,6 +42,8 @@ enum class VkErrorCode(val code: Int) { ACCESS_TO_DOC_DENIED(1153), SOME_AUTH_ERROR(104), + + CANNOT_SEND_MESSAGE_DUE_TO_PRIVACY_SETTINGS(902), ACCESS_TOKEN_EXPIRED(1117); companion object { diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt index 25f1b6ad..e5a16e09 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt @@ -1,12 +1,13 @@ 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.responses.MessagesCreateChatResponse import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.RestApiError -import com.slack.eithernet.ApiResult import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.POST @@ -49,6 +50,12 @@ interface MessagesService { @FieldMap params: Map ): ApiResult, RestApiError> + @FormUrlEncoded + @POST(MessagesUrls.CREATE_CHAT) + suspend fun createChat( + @FieldMap params: Map + ): ApiResult, RestApiError> + // @FormUrlEncoded // @POST(MessagesUrls.MarkAsImportant) // suspend fun markAsImportant( diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt index a3785574..2de07a9f 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesUrls.kt @@ -19,4 +19,5 @@ object MessagesUrls { const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers" const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser" const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments" + const val CREATE_CHAT = "${AppConstants.URL_API}/messages.createChat" } diff --git a/core/presentation/.gitignore b/core/presentation/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/presentation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/presentation/build.gradle.kts b/core/presentation/build.gradle.kts new file mode 100644 index 00000000..d0873abe --- /dev/null +++ b/core/presentation/build.gradle.kts @@ -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 { +} diff --git a/core/presentation/src/main/AndroidManifest.xml b/core/presentation/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8bdb7e14 --- /dev/null +++ b/core/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 002e8b00..a9232f56 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -11,6 +11,7 @@ android { dependencies { api(projects.core.common) api(projects.core.model) + implementation(projects.core.presentation) implementation(libs.haze) implementation(libs.haze.materials) diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt index 32b3db31..a7140fec 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/ErrorView.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -31,7 +32,8 @@ fun ErrorView( ) { Text( text = text, - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center ) buttonText?.let { diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/IconButton.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/IconButton.kt index e6516beb..0148d85d 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/IconButton.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/IconButton.kt @@ -29,9 +29,9 @@ import androidx.compose.ui.unit.dp @OptIn(ExperimentalFoundationApi::class) @Composable fun IconButton( + modifier: Modifier = Modifier, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, - modifier: Modifier = Modifier, enabled: Boolean = true, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), interactionSource: MutableInteractionSource? = null, diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt index b87ca10c..3c7a0d84 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/NoItemsView.kt @@ -1,29 +1,51 @@ 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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button 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.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import dev.meloda.fast.ui.R @Composable fun NoItemsView( modifier: Modifier = Modifier, - customText: String? = null + customText: String? = null, + buttonText: String? = null, + onButtonClick: (() -> Unit)? = null, ) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = customText ?: stringResource(id = R.string.no_items), - style = MaterialTheme.typography.titleLarge + text = customText ?: stringResource(R.string.no_items), + 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 private fun NoItemsViewPreview() { NoItemsView( - customText = "Nothing here..." + customText = "Nothing here...", + buttonText = "Refresh" ) } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ActionState.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ActionState.kt similarity index 91% rename from feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ActionState.kt rename to core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ActionState.kt index cd947872..73e07a1d 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ActionState.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ActionState.kt @@ -1,4 +1,4 @@ -package dev.meloda.fast.conversations.model +package dev.meloda.fast.ui.model.api enum class ActionState { PHANTOM, CALL_IN_PROGRESS, NONE; diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationOption.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationOption.kt new file mode 100644 index 00000000..775a26a0 --- /dev/null +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationOption.kt @@ -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) + ) +} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsShowOptions.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationsShowOptions.kt similarity index 87% rename from feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsShowOptions.kt rename to core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationsShowOptions.kt index df0700ec..66a77a8b 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsShowOptions.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/ConversationsShowOptions.kt @@ -1,4 +1,4 @@ -package dev.meloda.fast.conversations.model +package dev.meloda.fast.ui.model.api data class ConversationsShowOptions( val showDeleteDialog: Int?, diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/UiConversation.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiConversation.kt similarity index 95% rename from feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/UiConversation.kt rename to core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiConversation.kt index 172df568..9944284f 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/UiConversation.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiConversation.kt @@ -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.ui.text.AnnotatedString diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/UiFriend.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiFriend.kt similarity index 64% rename from feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/UiFriend.kt rename to core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiFriend.kt index 75b33bea..3e64e990 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/UiFriend.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/model/api/UiFriend.kt @@ -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.model.api.domain.OnlineStatus +@Immutable data class UiFriend( val userId: Int, val avatar: UiImage?, + val firstName: String, + val lastName: String, val title: String, val onlineStatus: OnlineStatus, val photo400Orig: UiImage? diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/Extensions.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/Extensions.kt index 211e7f58..3cfc47ad 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/Extensions.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/Extensions.kt @@ -1,7 +1,6 @@ package dev.meloda.fast.ui.util import android.content.res.Configuration -import android.graphics.drawable.ColorDrawable import android.os.PowerManager import android.view.KeyEvent 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.stringResource import androidx.core.content.getSystemService +import androidx.core.graphics.drawable.toDrawable import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiText @@ -64,8 +64,8 @@ fun UiImage.getResourcePainter(): Painter? { @Composable fun UiImage.getImage(): Any { return when (this) { - is UiImage.Color -> ColorDrawable(color) - is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb()) + is UiImage.Color -> color.toDrawable() + is UiImage.ColorResource -> colorResource(id = resId).toArgb().toDrawable() is UiImage.Resource -> painterResource(id = resId) is UiImage.Simple -> drawable is UiImage.Url -> url diff --git a/core/ui/src/main/res/drawable/baseline_account_circle_24.xml b/core/ui/src/main/res/drawable/baseline_account_circle_24.xml index 1e24cf39..fd0b7042 100644 --- a/core/ui/src/main/res/drawable/baseline_account_circle_24.xml +++ b/core/ui/src/main/res/drawable/baseline_account_circle_24.xml @@ -1,5 +1,11 @@ - - - - + + + + diff --git a/core/ui/src/main/res/drawable/baseline_chat_24.xml b/core/ui/src/main/res/drawable/baseline_chat_24.xml index 7f6fda16..1ae140a8 100644 --- a/core/ui/src/main/res/drawable/baseline_chat_24.xml +++ b/core/ui/src/main/res/drawable/baseline_chat_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/core/ui/src/main/res/drawable/baseline_people_alt_24.xml b/core/ui/src/main/res/drawable/baseline_people_alt_24.xml index 90c82148..3864d497 100644 --- a/core/ui/src/main/res/drawable/baseline_people_alt_24.xml +++ b/core/ui/src/main/res/drawable/baseline_people_alt_24.xml @@ -1,11 +1,27 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_round_add_circle_outline_24.xml b/core/ui/src/main/res/drawable/ic_round_add_circle_outline_24.xml deleted file mode 100644 index 855e6815..00000000 --- a/core/ui/src/main/res/drawable/ic_round_add_circle_outline_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/ui/src/main/res/drawable/ic_round_close_24.xml b/core/ui/src/main/res/drawable/ic_round_close_24.xml deleted file mode 100644 index 0aa41eb7..00000000 --- a/core/ui/src/main/res/drawable/ic_round_close_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/core/ui/src/main/res/drawable/outline_account_circle_24.xml b/core/ui/src/main/res/drawable/outline_account_circle_24.xml index c85da5ee..00ae294f 100644 --- a/core/ui/src/main/res/drawable/outline_account_circle_24.xml +++ b/core/ui/src/main/res/drawable/outline_account_circle_24.xml @@ -1,7 +1,15 @@ - - - - - - + + + + + + diff --git a/core/ui/src/main/res/drawable/outline_chat_24.xml b/core/ui/src/main/res/drawable/outline_chat_24.xml index 7ce81fa5..161149c8 100644 --- a/core/ui/src/main/res/drawable/outline_chat_24.xml +++ b/core/ui/src/main/res/drawable/outline_chat_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/core/ui/src/main/res/drawable/outline_people_alt_24.xml b/core/ui/src/main/res/drawable/outline_people_alt_24.xml index f3e073ee..4732046f 100644 --- a/core/ui/src/main/res/drawable/outline_people_alt_24.xml +++ b/core/ui/src/main/res/drawable/outline_people_alt_24.xml @@ -1,11 +1,23 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/round_access_time_24.xml b/core/ui/src/main/res/drawable/round_access_time_24.xml new file mode 100644 index 00000000..eef452fb --- /dev/null +++ b/core/ui/src/main/res/drawable/round_access_time_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_error_outline_24.xml b/core/ui/src/main/res/drawable/round_error_outline_24.xml new file mode 100644 index 00000000..c61c59cb --- /dev/null +++ b/core/ui/src/main/res/drawable/round_error_outline_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index ec25a7be..c137d3b7 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -128,7 +128,7 @@ Запись сообщества Запись пользователя Запись на стене - Выйти + Выйти Подтверждение Ваша история Динамические цвета @@ -212,4 +212,10 @@ Вы уверены? Процесс ввода капчи будет отменён Вы уверены? Процесс ввода кода-подтверждения будет отменён Авторизоваться + Никого в сети + Попробовать ещё раз + Срок действия сессии истёк + Создать чат + Создать + Название diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index d5a48e61..a9f1c0f4 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -119,7 +119,7 @@ Community post User post Post - Log out + Log out Confirmation Signing out will delete all data related to this account from this device. Continue? Yes @@ -276,6 +276,11 @@ Confirmation Are you sure? Captcha process will be cancelled Are you sure? Validation process will be cancelled - Enable pull to refresh Authorize + No one is online + Try again + Session expired + Create chat + Create + Title diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt index 5806134d..2551d953 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/ConversationsViewModel.kt @@ -11,10 +11,7 @@ import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.listenValue 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.ConversationsShowOptions -import dev.meloda.fast.conversations.model.UiConversation import dev.meloda.fast.conversations.util.asPresentation import dev.meloda.fast.conversations.util.extractAvatar import dev.meloda.fast.data.State @@ -29,10 +26,11 @@ import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.network.VkErrorCode +import dev.meloda.fast.ui.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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -40,7 +38,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException interface ConversationsViewModel { @@ -49,7 +46,6 @@ interface ConversationsViewModel { val baseError: StateFlow val currentOffset: StateFlow val canPaginate: StateFlow - val scrollToTop: StateFlow fun onPaginationConditionsMet() @@ -70,10 +66,6 @@ interface ConversationsViewModel { fun setScrollIndex(index: Int) fun setScrollOffset(offset: Int) - - - fun setScrollToTopFlow(scrollToTopFlow: Flow) - fun onScrolledToTop() } class ConversationsViewModelImpl( @@ -91,12 +83,8 @@ class ConversationsViewModelImpl( override val baseError = MutableStateFlow(null) override val currentOffset = MutableStateFlow(0) override val canPaginate = MutableStateFlow(false) - override val scrollToTop = MutableStateFlow(false) - // TODO: 22-Dec-24, Danil Nikolaev: rewrite - private val useContactNames = { - userSettings.useContactNames.value - } + private val useContactNames: Boolean get() = userSettings.useContactNames.value override fun onPaginationConditionsMet() { currentOffset.update { screenState.value.conversations.size } @@ -134,7 +122,7 @@ class ConversationsViewModelImpl( } override fun onRefresh() { - baseError.setValue { null } + onErrorConsumed() loadConversations(offset = 0) } @@ -177,11 +165,11 @@ class ConversationsViewModelImpl( conversations = old.conversations.map { item -> item.copy( isExpanded = - if (item.id == conversation.id) { - !item.isExpanded - } else { - false - }, + if (item.id == conversation.id) { + !item.isExpanded + } else { + false + }, options = ImmutableList.copyOf(options) ) } @@ -200,7 +188,10 @@ class ConversationsViewModelImpl( onPinDialogDismissed() } - override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) { + override fun onOptionClicked( + conversation: UiConversation, + option: ConversationOption + ) { when (option) { ConversationOption.Delete -> { emitShowOptions { old -> @@ -237,20 +228,6 @@ class ConversationsViewModelImpl( screenState.setValue { old -> old.copy(scrollOffset = offset) } } - override fun setScrollToTopFlow(scrollToTopFlow: Flow) { - scrollToTopFlow.listenValue(viewModelScope) { index -> - if (index == 1) { - scrollToTop.emit(true) - } - } - } - - override fun onScrolledToTop() { - viewModelScope.launch(Dispatchers.Main) { - scrollToTop.emit(false) - } - } - private fun hideOptions(conversationId: Int) { screenState.setValue { old -> old.copy( @@ -345,21 +322,25 @@ class ConversationsViewModelImpl( } } } + 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 } } @@ -383,7 +364,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -447,7 +428,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -498,7 +479,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -527,7 +508,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -557,7 +538,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -586,7 +567,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -632,7 +613,7 @@ class ConversationsViewModelImpl( old.copy(conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) }) } @@ -673,7 +654,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -724,7 +705,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) @@ -758,7 +739,7 @@ class ConversationsViewModelImpl( conversations = newConversations.map { it.asPresentation( resources = resources, - useContactName = useContactNames() + useContactName = useContactNames ) } ) diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationOption.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationOption.kt deleted file mode 100644 index 451ffa1a..00000000 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationOption.kt +++ /dev/null @@ -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) - ) -} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt index 27277405..c8026d52 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/model/ConversationsScreenState.kt @@ -1,6 +1,8 @@ package dev.meloda.fast.conversations.model import androidx.compose.runtime.Immutable +import dev.meloda.fast.ui.model.api.ConversationsShowOptions +import dev.meloda.fast.ui.model.api.UiConversation @Immutable data class ConversationsScreenState( diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt index ccc91b6c..0cf05c68 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt @@ -8,7 +8,6 @@ import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.conversations.presentation.ConversationsRoute import dev.meloda.fast.model.BaseError import dev.meloda.fast.ui.extensions.sharedViewModel -import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable @Serializable @@ -18,18 +17,18 @@ fun NavGraphBuilder.conversationsScreen( onError: (BaseError) -> Unit, onConversationItemClicked: (id: Int) -> Unit, onPhotoClicked: (url: String) -> Unit, - scrollToTopFlow: Flow, + onCreateChatClicked: () -> Unit, navController: NavController, ) { composable { val viewModel: ConversationsViewModel = it.sharedViewModel(navController = navController) - viewModel.setScrollToTopFlow(scrollToTopFlow) ConversationsRoute( onError = onError, onConversationItemClicked = onConversationItemClicked, onConversationPhotoClicked = onPhotoClicked, + onCreateChatButtonClicked = onCreateChatClicked, viewModel = viewModel ) } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt index 9e270ff6..ed0751f0 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt @@ -48,11 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.LocalContentAlpha 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.getResourcePainter import dev.meloda.fast.ui.util.getString @@ -256,7 +256,7 @@ fun ConversationItem( Row { if (conversation.interactionText != null) { Text( - text = conversation.interactionText, + text = conversation.interactionText.orEmpty(), color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(4.dp)) diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt index 835a2677..666827c6 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt @@ -5,7 +5,6 @@ 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 @@ -23,10 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.UiConversation 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -83,8 +82,7 @@ fun ConversationsList( Column( modifier = Modifier .fillMaxWidth() - .animateItem(fadeInSpec = null, fadeOutSpec = null) - .navigationBarsPadding(), + .animateItem(fadeInSpec = null, fadeOutSpec = null), horizontalAlignment = Alignment.CenterHorizontally ) { if (screenState.isPaginating) { @@ -107,11 +105,9 @@ fun ConversationsList( ) } } - } - } - item { - Spacer(modifier = Modifier.height(bottomPadding)) + Spacer(modifier = Modifier.height(8.dp)) + } } } } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt index e7c42f1a..863fafef 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt @@ -2,7 +2,6 @@ package dev.meloda.fast.conversations.presentation import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -48,12 +47,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -62,28 +59,26 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.core.view.HapticFeedbackConstantsCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.chrisbanes.haze.haze -import dev.chrisbanes.haze.hazeChild +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.ConversationsViewModel -import dev.meloda.fast.conversations.model.ConversationOption 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.ui.components.ErrorView import dev.meloda.fast.ui.components.FullScreenLoader 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.LocalHazeState import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.isScrollingUp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch import dev.meloda.fast.ui.R as UiR @Composable @@ -91,12 +86,12 @@ fun ConversationsRoute( onError: (BaseError) -> Unit, onConversationItemClicked: (conversationId: Int) -> Unit, onConversationPhotoClicked: (url: String) -> Unit, + onCreateChatButtonClicked: () -> Unit, viewModel: ConversationsViewModel ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() - val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() ConversationsScreen( screenState = screenState, @@ -113,10 +108,9 @@ fun ConversationsRoute( onRefreshDropdownItemClicked = viewModel::onRefresh, onRefresh = viewModel::onRefresh, onConversationPhotoClicked = onConversationPhotoClicked, + onCreateChatButtonClicked = onCreateChatButtonClicked, setScrollIndex = viewModel::setScrollIndex, - setScrollOffset = viewModel::setScrollOffset, - isNeedToScrollToTop = isNeedToScrollToTop, - onScrolledToTop = viewModel::onScrolledToTop + setScrollOffset = viewModel::setScrollOffset ) HandleDialogs( @@ -134,7 +128,7 @@ fun ConversationsScreen( screenState: ConversationsScreenState = ConversationsScreenState.EMPTY, baseError: BaseError? = null, canPaginate: Boolean = false, - onSessionExpiredLogOutButtonClicked: () -> Unit, + onSessionExpiredLogOutButtonClicked: () -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {}, onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> }, @@ -142,10 +136,9 @@ fun ConversationsScreen( onRefreshDropdownItemClicked: () -> Unit = {}, onRefresh: () -> Unit = {}, onConversationPhotoClicked: (url: String) -> Unit = {}, + onCreateChatButtonClicked: () -> Unit = {}, setScrollIndex: (Int) -> Unit = {}, - setScrollOffset: (Int) -> Unit = {}, - isNeedToScrollToTop: Boolean = false, - onScrolledToTop: () -> Unit = {} + setScrollOffset: (Int) -> Unit = {} ) { val view = LocalView.current val currentTheme = LocalThemeConfig.current @@ -159,14 +152,6 @@ fun ConversationsScreen( initialFirstVisibleItemScrollOffset = screenState.scrollOffset ) - LaunchedEffect(isNeedToScrollToTop) { - if (isNeedToScrollToTop) { - listState.scrollToItem(0) - onScrolledToTop() - - } - } - LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } .debounce(500L) @@ -207,10 +192,10 @@ fun ConversationsScreen( val toolbarContainerColor by animateColorAsState( targetValue = - if (currentTheme.enableBlur || !listState.canScrollBackward) - MaterialTheme.colorScheme.surface - else - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + if (currentTheme.enableBlur || !listState.canScrollBackward) + MaterialTheme.colorScheme.surface + else + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), label = "toolbarColorAlpha", animationSpec = tween(durationMillis = 50) ) @@ -275,7 +260,7 @@ fun ConversationsScreen( modifier = Modifier .then( if (currentTheme.enableBlur) { - Modifier.hazeChild( + Modifier.hazeEffect( state = hazeState, style = HazeMaterials.thick() ) @@ -296,37 +281,13 @@ fun ConversationsScreen( } }, floatingActionButton = { - val scope = rememberCoroutineScope() - val rotation = remember { Animatable(0f) } - Column { AnimatedVisibility( visible = listState.isScrollingUp(), enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)), exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200)) ) { - FloatingActionButton( - 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) - ) { + FloatingActionButton(onClick = onCreateChatButtonClicked) { Icon( painter = painterResource(id = UiR.drawable.ic_baseline_create_24), contentDescription = "Add chat button" @@ -343,8 +304,8 @@ fun ConversationsScreen( when (baseError) { is BaseError.SessionExpired -> { ErrorView( - text = "Session expired", - buttonText = "Log out", + text = stringResource(UiR.string.session_expired), + buttonText = stringResource(UiR.string.action_log_out), onButtonClick = onSessionExpiredLogOutButtonClicked ) } @@ -352,7 +313,7 @@ fun ConversationsScreen( is BaseError.SimpleError -> { ErrorView( text = baseError.message, - buttonText = "Try again", + buttonText = stringResource(UiR.string.try_again), onButtonClick = onRefresh ) } @@ -390,7 +351,7 @@ fun ConversationsScreen( state = listState, maxLines = maxLines, modifier = if (currentTheme.enableBlur) { - Modifier.haze(state = hazeState) + Modifier.hazeSource(state = hazeState) } else { Modifier }.fillMaxSize(), @@ -398,6 +359,13 @@ fun ConversationsScreen( padding = padding, onPhotoClicked = onConversationPhotoClicked ) + + if (screenState.conversations.isEmpty()) { + NoItemsView( + buttonText = stringResource(UiR.string.action_refresh), + onButtonClick = onRefresh + ) + } } } } @@ -422,9 +390,7 @@ fun HandleDialogs( ) } - if (showOptions.showPinDialog != null) { - val conversation = showOptions.showPinDialog - + showOptions.showPinDialog?.let { conversation -> MaterialDialog( onDismissRequest = viewModel::onPinDialogDismissed, title = stringResource( diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt index cd267345..56634b9d 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt @@ -14,8 +14,6 @@ import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.parseString 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.VkMemoryCache 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.VkConversation 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 java.util.Calendar import java.util.Locale diff --git a/feature/createchat/.gitignore b/feature/createchat/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/createchat/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/createchat/build.gradle.kts b/feature/createchat/build.gradle.kts new file mode 100644 index 00000000..23b4e5ab --- /dev/null +++ b/feature/createchat/build.gradle.kts @@ -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) +} diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/CreateChatViewModel.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/CreateChatViewModel.kt new file mode 100644 index 00000000..c9847dbe --- /dev/null +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/CreateChatViewModel.kt @@ -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 + val baseError: StateFlow + val currentOffset: StateFlow + val canPaginate: StateFlow + + val isChatCreated: StateFlow + + 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(null) + override val currentOffset = MutableStateFlow(0) + override val canPaginate = MutableStateFlow(false) + + override val isChatCreated = MutableStateFlow(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 + } +} diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/di/CreateChatModule.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/di/CreateChatModule.kt new file mode 100644 index 00000000..2b9ec1b5 --- /dev/null +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/di/CreateChatModule.kt @@ -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) +} diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/model/CreateChatScreenState.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/model/CreateChatScreenState.kt new file mode 100644 index 00000000..03abef2c --- /dev/null +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/model/CreateChatScreenState.kt @@ -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, + val selectedFriendsIds: List, + val chatTitle: String +) { + companion object { + val EMPTY: CreateChatScreenState = CreateChatScreenState( + isLoading = true, + isPaginating = false, + isPaginationExhausted = false, + friends = emptyList(), + selectedFriendsIds = emptyList(), + chatTitle = "" + ) + } +} diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/navigation/CreateChatNavigation.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/navigation/CreateChatNavigation.kt new file mode 100644 index 00000000..3a863420 --- /dev/null +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/navigation/CreateChatNavigation.kt @@ -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 { + val viewModel: CreateChatViewModel = + it.sharedViewModel(navController = navController) + + CreateChatRoute( + onError = { + + }, + onBack = navController::popBackStack, + onChatCreated = onChatCreated, + viewModel = viewModel + ) + } +} + +fun NavController.navigateToCreateChat() { + this.navigate(CreateChat) +} diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatItem.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatItem.kt new file mode 100644 index 00000000..e5ff6457 --- /dev/null +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatItem.kt @@ -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)) + } +} + diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatList.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatList.kt new file mode 100644 index 00000000..ff2c00f2 --- /dev/null +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatList.kt @@ -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()) + } + } + } +} diff --git a/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt new file mode 100644 index 00000000..b2d331e4 --- /dev/null +++ b/feature/createchat/src/main/kotlin/dev/meloda/fast/conversations/presentation/CreateChatScreen.kt @@ -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 + ) + } + } + } + } + } +} diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt index c01de124..bf953845 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/FriendsViewModel.kt @@ -9,8 +9,8 @@ import dev.meloda.fast.data.processState import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.domain.FriendsUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase +import dev.meloda.fast.domain.util.asPresentation 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.api.domain.VkUser import dev.meloda.fast.network.VkErrorCode @@ -68,6 +68,7 @@ class FriendsViewModelImpl( } override fun onRefresh() { + onErrorConsumed() loadFriends(offset = 0) } @@ -99,32 +100,12 @@ class FriendsViewModelImpl( friendsUseCase.getOnlineFriends(null, null) .listenValue(viewModelScope) { state -> state.processState( - error = { error -> - if (error is State.Error.ApiError) { - when (error.errorCode) { - VkErrorCode.USER_AUTHORIZATION_FAILED -> { - baseError.setValue { BaseError.SessionExpired } - } - - else -> Unit - } - } - }, + error = ::handleError, success = { userIds -> loadUsersByIdsUseCase(userIds = userIds) .listenValue(viewModelScope) { state -> state.processState( - error = { error -> - if (error is State.Error.ApiError) { - when (error.errorCode) { - VkErrorCode.USER_AUTHORIZATION_FAILED -> { - baseError.setValue { BaseError.SessionExpired } - } - - else -> Unit - } - } - }, + error = ::handleError, success = { onlineFriends -> screenState.setValue { old -> old.copy( @@ -142,17 +123,7 @@ class FriendsViewModelImpl( friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset) .listenValue(viewModelScope) { state -> state.processState( - error = { error -> - if (error is State.Error.ApiError) { - when (error.errorCode) { - VkErrorCode.USER_AUTHORIZATION_FAILED -> { - baseError.setValue { BaseError.SessionExpired } - } - - else -> Unit - } - } - }, + error = ::handleError, success = { response -> val itemsCountSufficient = response.size == LOAD_COUNT 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) { val friends = friends.value if (friends.isEmpty()) return diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt index 59d5ca9a..f2583c3a 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/model/FriendsScreenState.kt @@ -1,6 +1,7 @@ package dev.meloda.fast.friends.model import androidx.compose.runtime.Immutable +import dev.meloda.fast.ui.model.api.UiFriend @Immutable data class FriendsScreenState( diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt index 261718e1..9fdaa30e 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/navigation/FriendsNavigation.kt @@ -16,7 +16,8 @@ object Friends fun NavGraphBuilder.friendsScreen( onError: (BaseError) -> Unit, navController: NavController, - onPhotoClicked: (url: String) -> Unit + onPhotoClicked: (url: String) -> Unit, + onMessageClicked: (userId: Int) -> Unit ) { composable { val viewModel: FriendsViewModel = @@ -25,7 +26,8 @@ fun NavGraphBuilder.friendsScreen( FriendsRoute( onError = onError, viewModel = viewModel, - onPhotoClicked = onPhotoClicked + onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked ) } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt index 98f482a4..78e40aff 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt @@ -12,6 +12,10 @@ 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.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.Text 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.sp import coil.compose.AsyncImage -import dev.meloda.fast.friends.model.UiFriend import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.model.api.UiFriend @Composable fun FriendItem( modifier: Modifier = Modifier, friend: UiFriend, maxLines: Int, - onPhotoClicked: (url: String) -> Unit + onPhotoClicked: (url: String) -> Unit, + onMessageClicked: (userId: Int) -> Unit ) { Row( modifier = modifier.fillMaxWidth(), @@ -92,9 +97,24 @@ fun FriendItem( text = friend.title, minLines = 1, 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)) + + IconButton( + onClick = { + onMessageClicked(friend.userId) + } + ) { + Icon( + imageVector = Icons.Rounded.MailOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.width(16.dp)) } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt index 13f9c1b1..c63075ee 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsList.kt @@ -5,7 +5,6 @@ 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 @@ -23,8 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.meloda.fast.friends.model.FriendsScreenState -import dev.meloda.fast.friends.model.UiFriend -import dev.meloda.fast.ui.theme.LocalBottomPadding +import dev.meloda.fast.ui.model.api.UiFriend import dev.meloda.fast.ui.util.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -38,6 +36,7 @@ fun FriendsList( maxLines: Int, padding: PaddingValues, onPhotoClicked: (url: String) -> Unit, + onMessageClicked: (userId: Int) -> Unit, setCanScrollBackward: (Boolean) -> Unit ) { LaunchedEffect(listState) { @@ -49,8 +48,6 @@ fun FriendsList( val friends = uiFriends.toList() - val bottomPadding = LocalBottomPadding.current - LazyColumn( modifier = modifier, state = listState @@ -67,7 +64,8 @@ fun FriendsList( FriendItem( friend = friend, maxLines = maxLines, - onPhotoClicked = onPhotoClicked + onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked ) Spacer(modifier = Modifier.height(16.dp)) @@ -77,8 +75,7 @@ fun FriendsList( Column( modifier = Modifier .fillMaxWidth() - .animateItem(fadeInSpec = null, fadeOutSpec = null) - .navigationBarsPadding(), + .animateItem(fadeInSpec = null, fadeOutSpec = null), horizontalAlignment = Alignment.CenterHorizontally ) { if (screenState.isPaginating) { @@ -101,11 +98,9 @@ fun FriendsList( ) } } - } - } - item { - Spacer(modifier = Modifier.height(bottomPadding)) + Spacer(modifier = Modifier.height(8.dp)) + } } } } diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt index c6655c4e..d3560226 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendsScreen.kt @@ -48,8 +48,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.imageLoader import coil.request.ImageRequest -import dev.chrisbanes.haze.haze -import dev.chrisbanes.haze.hazeChild +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.meloda.fast.friends.FriendsViewModel @@ -72,6 +72,7 @@ import dev.meloda.fast.ui.R as UiR fun FriendsRoute( onError: (BaseError) -> Unit, onPhotoClicked: (url: String) -> Unit, + onMessageClicked: (userId: Int) -> Unit, viewModel: FriendsViewModel = koinViewModel() ) { val context = LocalContext.current @@ -99,11 +100,12 @@ fun FriendsRoute( onPaginationConditionsMet = viewModel::onPaginationConditionsMet, onRefresh = viewModel::onRefresh, onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked, setSelectedTabIndex = viewModel::onTabSelected, setScrollIndex = viewModel::setScrollIndex, setScrollOffset = viewModel::setScrollOffset, setScrollIndexOnline = viewModel::setScrollIndexOnline, - setScrollOffsetOnline = viewModel::setScrollOffsetOnline, + setScrollOffsetOnline = viewModel::setScrollOffsetOnline ) } @@ -120,11 +122,12 @@ fun FriendsScreen( onPaginationConditionsMet: () -> Unit = {}, onRefresh: () -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {}, + onMessageClicked: (userId: Int) -> Unit = {}, setSelectedTabIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {}, setScrollOffset: (Int) -> Unit = {}, setScrollIndexOnline: (Int) -> Unit = {}, - setScrollOffsetOnline: (Int) -> Unit = {}, + setScrollOffsetOnline: (Int) -> Unit = {} ) { val currentTheme = LocalThemeConfig.current @@ -231,7 +234,7 @@ fun FriendsScreen( modifier = Modifier .then( if (currentTheme.enableBlur) { - Modifier.hazeChild( + Modifier.hazeEffect( state = hazeState, style = HazeMaterials.thick() ) @@ -281,12 +284,24 @@ fun FriendsScreen( } ) { padding -> when { - baseError is BaseError.SessionExpired -> { - ErrorView( - text = "Session expired", - buttonText = "Log out", - onButtonClick = onSessionExpiredLogOutButtonClicked - ) + 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() @@ -333,15 +348,17 @@ fun FriendsScreen( ) } ) { - val friendsToDisplay = if (index == 0) { - screenState.friends - } else { - screenState.onlineFriends + val friendsToDisplay = remember(index) { + if (index == 0) { + screenState.friends + } else { + screenState.onlineFriends + } } FriendsList( modifier = if (currentTheme.enableBlur) { - Modifier.haze(state = hazeState) + Modifier.hazeSource(state = hazeState) } else { Modifier }.fillMaxSize(), @@ -351,6 +368,7 @@ fun FriendsScreen( maxLines = maxLines, padding = padding, onPhotoClicked = onPhotoClicked, + onMessageClicked = onMessageClicked, setCanScrollBackward = { can -> canScrollBackward = can } @@ -358,10 +376,9 @@ fun FriendsScreen( if (friendsToDisplay.isEmpty()) { NoItemsView( - modifier = Modifier - .padding(padding.calculateTopPadding()) - .padding(top = 16.dp), - customText = "No${if (index == 1) " online" else ""} friends :(" + customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null, + buttonText = stringResource(UiR.string.action_refresh), + onButtonClick = onRefresh ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index c0f542d5..27f046df 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -24,11 +24,13 @@ import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.messageshistory.model.ActionMode 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.navigation.MessagesHistory import dev.meloda.fast.messageshistory.util.asPresentation import dev.meloda.fast.messageshistory.util.extractAvatar 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.LongPollEvent import dev.meloda.fast.model.api.domain.VkAttachment @@ -160,7 +162,9 @@ class MessagesHistoryViewModelImpl( val message = event.message Log.d("MessagesHistoryViewModel", "handleNewMessage: $message") + if (message.peerId != screenState.value.conversationId) return + if (screenState.value.messages.findMessageById(message.id) != null) return val randomIds = messages.value.map(VkMessage::randomId) if (message.randomId != 0 && message.randomId in randomIds) return @@ -174,22 +178,22 @@ class MessagesHistoryViewModelImpl( val newMessage = message.asPresentation( resourceProvider = resourceProvider, - showDate = false, showName = false, prevMessage = prevMessage, nextMessage = null, - showTimeInActionMessages = userSettings.showTimeInActionMessages.value + showTimeInActionMessages = userSettings.showTimeInActionMessages.value, + conversation = screenState.value.conversation, ) newMessages.add(0, newMessage) prevMessage?.let { prev -> newMessages[1] = prev.asPresentation( resourceProvider = resourceProvider, - showDate = false, showName = false, prevMessage = prevMessage, nextMessage = messages.value.first(), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value + showTimeInActionMessages = userSettings.showTimeInActionMessages.value, + conversation = screenState.value.conversation ) } @@ -205,11 +209,11 @@ class MessagesHistoryViewModelImpl( ?.let { index -> val newMessage = message.asPresentation( resourceProvider = resourceProvider, - showDate = false, showName = false, prevMessage = 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() @@ -224,7 +228,37 @@ class MessagesHistoryViewModelImpl( } private fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) { + 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) { @@ -236,9 +270,7 @@ class MessagesHistoryViewModelImpl( offset = offset, ).listenValue(viewModelScope) { state -> state.processState( - error = { error -> - - }, + error = { error -> }, success = { response -> val messages = response.messages val fullMessages = if (offset == 0) { @@ -256,16 +288,6 @@ class MessagesHistoryViewModelImpl( messagesUseCase.storeMessages(messages) 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 @@ -278,15 +300,28 @@ class MessagesHistoryViewModelImpl( conversations .firstOrNull { it.id == screenState.value.conversationId } ?.let { conversation -> + screenState.setValue { old -> old.copy(conversation = conversation) } newState = newState.copy( title = conversation.extractTitle( useContactName = AppSettings.General.useContactNames, 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) screenState.setValue { newState.copy(messages = loadedMessages) } canPaginate.setValue { itemsCountSufficient } @@ -347,18 +382,14 @@ class MessagesHistoryViewModelImpl( val newMessages = screenState.value.messages.toMutableList() val newUiMessage = newMessage.asPresentation( resourceProvider = resourceProvider, - showDate = false, showName = false, prevMessage = messages.value.firstOrNull(), nextMessage = null, - showTimeInActionMessages = userSettings.showTimeInActionMessages.value + showTimeInActionMessages = userSettings.showTimeInActionMessages.value, + conversation = screenState.value.conversation ) newMessages.add(0, newUiMessage) - messages.setValue { old -> - listOf(newMessage).plus(old) - } - screenState.setValue { old -> old.copy( message = TextFieldValue(), @@ -377,19 +408,37 @@ class MessagesHistoryViewModelImpl( state.processState( error = { error -> sendingMessages -= newMessage - }, - success = { messageId -> - sendingMessages += newMessage - val messages = screenState.value.messages.toMutableList() + val uiMessages = screenState.value.messages.toMutableList() - messages.indexOfOrNull(newUiMessage)?.let { index -> - (messages[index] as? UiItem.Message)?.let { message -> - messages[index] = message.copy(id = messageId) + uiMessages.indexOfOrNull(newUiMessage)?.let { index -> + (uiMessages[index] as? UiItem.Message)?.let { message -> + 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 +557,11 @@ class MessagesHistoryViewModelImpl( val uiMessages = messages.mapIndexed { index, item -> item.asPresentation( resourceProvider = resourceProvider, - showDate = false, showName = false, prevMessage = messages.getOrNull(index + 1), nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = show + showTimeInActionMessages = show, + conversation = screenState.value.conversation ) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt index a8312f0a..815e2d56 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.model.api.domain.VkAttachment +import dev.meloda.fast.model.api.domain.VkConversation @Immutable data class MessagesHistoryScreenState( @@ -18,7 +19,8 @@ data class MessagesHistoryScreenState( val isPaginating: Boolean, val isPaginationExhausted: Boolean, val actionMode: ActionMode, - val chatImageUrl: String? + val chatImageUrl: String?, + val conversation: VkConversation ) { companion object { @@ -34,7 +36,8 @@ data class MessagesHistoryScreenState( isPaginating = false, isPaginationExhausted = false, actionMode = ActionMode.Record, - chatImageUrl = null + chatImageUrl = null, + conversation = VkConversation.EMPTY ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/SendingStatus.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/SendingStatus.kt new file mode 100644 index 00000000..48bd9d92 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/SendingStatus.kt @@ -0,0 +1,5 @@ +package dev.meloda.fast.messageshistory.model + +enum class SendingStatus { + SENDING, SENT, FAILED +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt index f2a98738..1eadb2d0 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt @@ -22,7 +22,9 @@ sealed class UiItem( val showAvatar: Boolean, val showName: Boolean, val avatar: UiImage, - val isEdited: Boolean + val isEdited: Boolean, + val isRead: Boolean, + val sendingStatus: SendingStatus = SendingStatus.SENT ) : UiItem(id, conversationMessageId) data class ActionMessage( @@ -32,4 +34,3 @@ sealed class UiItem( val actionCmId: Int? ) : UiItem(id, conversationMessageId) } - diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt index a000ae57..d01bdef3 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt @@ -31,6 +31,7 @@ import dev.meloda.fast.messageshistory.model.UiItem fun IncomingMessageBubble( modifier: Modifier = Modifier, message: UiItem.Message, + animate: Boolean ) { val context = LocalContext.current @@ -44,12 +45,12 @@ fun IncomingMessageBubble( if (message.isInChat) { Image( painter = - message.avatar.extractUrl()?.let { url -> - rememberAsyncImagePainter( - model = url, - imageLoader = context.imageLoader - ) - } ?: painterResource(id = message.avatar.extractResId()), + message.avatar.extractUrl()?.let { url -> + rememberAsyncImagePainter( + model = url, + imageLoader = context.imageLoader + ) + } ?: painterResource(id = message.avatar.extractResId()), contentDescription = null, modifier = Modifier .padding(bottom = 6.dp) @@ -80,6 +81,9 @@ fun IncomingMessageBubble( isOut = false, date = message.date, edited = message.isEdited, + animate = animate, + isRead = message.isRead, + sendingStatus = message.sendingStatus ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt index 9f20c622..f4c1459c 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt @@ -1,19 +1,35 @@ package dev.meloda.fast.messageshistory.presentation import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background 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.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn 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.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 dev.meloda.fast.messageshistory.model.SendingStatus +import dev.meloda.fast.ui.R as UiR @Composable fun MessageBubble( @@ -22,6 +38,9 @@ fun MessageBubble( isOut: Boolean, date: String?, edited: Boolean, + animate: Boolean, + isRead: Boolean, + sendingStatus: SendingStatus ) { val backgroundColor = if (!isOut) { MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) @@ -45,44 +64,70 @@ fun MessageBubble( 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) { Text( text = text, modifier = Modifier .padding(2.dp) .align(Alignment.Center) - .animateContentSize(), + .padding(end = 4.dp) + .padding(end = dateContainerWidth) + .padding(end = 4.dp) + .then(if (animate) Modifier.animateContentSize() else Modifier), 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( -// targetValue = if (edited) 50.dp else 30.dp, -// label = "dateContainerWidth" -// ) + if (isOut) { + Icon( + 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( -// date != null, -// modifier = Modifier -// .width(dateContainerWidth) -// .align(Alignment.BottomEnd) -// ) { -// 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)) -// } -// } + SendingStatus.FAILED -> UiR.drawable.round_error_outline_24 + } + ), + tint = if (sendingStatus == SendingStatus.FAILED) Color.Red + else LocalContentColor.current, + contentDescription = null + ) + } + } } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index 9f2d2fe1..a52702c2 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -159,7 +159,7 @@ fun MessagesHistoryScreen( val listState = rememberLazyListState() - val paginationConditionMet by remember { + val paginationConditionMet by remember(canPaginate, listState) { derivedStateOf { canPaginate && (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index @@ -282,6 +282,7 @@ fun MessagesHistoryScreen( // TODO: 11/07/2024, Danil Nikolaev: to VM + // TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat) onChatMaterialsDropdownItemClicked( screenState.conversationId, screenState.messages.firstMessage().conversationMessageId diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index b329eeee..ad9b7c0e 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -90,26 +90,28 @@ fun MessagesList( if (item.isOut) { OutgoingMessageBubble( modifier = - Modifier.then( - if (enableAnimations) Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) - else Modifier - ), + Modifier.then( + if (enableAnimations) Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) + else Modifier + ), message = item, + animate = enableAnimations ) } else { IncomingMessageBubble( modifier = - Modifier.then( - if (enableAnimations) Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) - else Modifier - ), + Modifier.then( + if (enableAnimations) Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) + else Modifier + ), message = item, + animate = enableAnimations ) } } @@ -128,16 +130,17 @@ fun MessagesList( } } - Spacer( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - ) + Spacer(Modifier.height(8.dp)) Spacer( modifier = Modifier .height(64.dp) .fillMaxWidth() ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + ) } } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt index dc1ad033..f25a1bdb 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt @@ -3,12 +3,8 @@ package dev.meloda.fast.messageshistory.presentation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -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 @@ -20,6 +16,7 @@ import dev.meloda.fast.messageshistory.model.UiItem fun OutgoingMessageBubble( modifier: Modifier = Modifier, message: UiItem.Message, + animate: Boolean ) { Row( modifier = modifier.fillMaxWidth(), @@ -37,18 +34,12 @@ fun OutgoingMessageBubble( modifier = Modifier, text = message.text.orDots(), isOut = true, - date = null, + date = message.date, 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 - ) - } } } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt index de916b08..36de0c13 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/Ext.kt @@ -4,11 +4,13 @@ import dev.meloda.fast.messageshistory.model.UiItem fun List.firstMessage(): UiItem.Message = filterIsInstance().first() +fun List.firstMessageOrNull(): UiItem.Message? = filterIsInstance().firstOrNull() + fun List.indexOfMessageById(messageId: Int): Int = indexOfFirst { it.id == messageId } -fun List.findMessageById(messageId: Int): UiItem.Message = - first { it.id == messageId } as UiItem.Message +fun List.findMessageById(messageId: Int): UiItem.Message? = + firstOrNull { it.id == messageId } as UiItem.Message? fun List.indexOfMessageByCmId(cmId: Int): Int = indexOfFirst { it.cmId == cmId } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt index 233fc3dc..94c8ac6f 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt @@ -12,6 +12,7 @@ import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.VkMemoryCache +import dev.meloda.fast.messageshistory.model.SendingStatus import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.domain.VkConversation @@ -90,8 +91,8 @@ fun VkConversation.extractTitle( }.parseString(resources).orDots() fun VkMessage.asPresentation( + conversation: VkConversation, resourceProvider: ResourceProvider, - showDate: Boolean, showName: Boolean, prevMessage: VkMessage?, nextMessage: VkMessage?, @@ -118,15 +119,19 @@ fun VkMessage.asPresentation( randomId = randomId, isInChat = isPeerChat(), name = extractTitle(), - showDate = showDate, + showDate = true, showAvatar = extractShowAvatar(nextMessage), showName = showName && extractShowName(prevMessage), 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 { if (isOut) return false return nextMessage == null || nextMessage.fromId != fromId diff --git a/settings.gradle.kts b/settings.gradle.kts index 8f5298d3..affd20d8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,13 +19,16 @@ dependencyResolutionManagement { rootProject.name = "fast-messenger" include(":app") + include(":core:network") include(":core:data") include(":core:database") include(":core:datastore") include(":core:ui") include(":core:common") +include(":core:domain") include(":core:model") + include(":feature:messageshistory") include(":feature:conversations") include(":feature:auth") @@ -35,4 +38,5 @@ include(":feature:photoviewer") include(":feature:settings") include(":feature:friends") include(":feature:profile") -include(":core:domain") +include(":feature:createchat") +include(":core:presentation")