Release 0.1.8 (#139)

* pagination in chat fixed
* other fixes and improvements

* fixed visual bug in progress bar in chat history

* Refactor: Enhance conversations and friends features

-   In `ConversationsScreen`, removed `isNeedToScrollToTop` and `onScrolledToTop`, and refactored toolbar container color logic. Added `NoItemsView` for empty conversation lists.
-   In `MainGraph`, added `onMessageClicked` for navigation to message history.
-   In `ApiEvent`, introduced `parseOrNull` for handling unknown event types.
-   In `ConversationsViewModel`, removed `scrollToTop` logic and refactored error handling.
-   In `FriendsViewModel`, refactored error handling and introduced `onErrorConsumed` and `handleError`.
-   In `FriendItem`, added an icon button to initiate sending a message to a friend.
-   In `strings.xml`, added or updated strings for session expiration, log out, refreshing, and empty friend lists.
-   In `RootScreen`, added `onMessageClicked` for navigating to messages.
-   In `FriendsList`, added `onMessageClicked` for handling message clicks.
-   In `MainScreen`, removed unused `MutableSharedFlow`.
-   In `FriendsScreen`, added support for showing errors, added `onMessageClicked`, and replaced `hazeChild` with `hazeEffect` and `hazeSource`.
-   In `FriendsNavigation`, added `onMessageClicked` for handling message clicks.
-   In `ConversationsNavigation`, removed the unused `scrollToTopFlow` parameter.
-   In `ErrorView`, added text alignment.
-   In `NoItemsView`, added support for a button and custom text.
-   In `LongPollUpdatesParser`, replaced try-catch with `parseOrNull`.

* Chat creation feature (#138)

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

* message sending status
This commit is contained in:
2025-03-23 09:22:41 +03:00
committed by GitHub
parent 30e132d418
commit b2879d8756
81 changed files with 1687 additions and 458 deletions
+1
View File
@@ -77,6 +77,7 @@ dependencies {
implementation(projects.feature.friends) implementation(projects.feature.friends)
implementation(projects.feature.profile) implementation(projects.feature.profile)
implementation(projects.feature.photoviewer) implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.ui) implementation(projects.core.ui)
@@ -16,6 +16,7 @@ import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.conversations.di.conversationsModule import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.conversations.di.createChatModule
import dev.meloda.fast.domain.di.domainModule import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule import dev.meloda.fast.languagepicker.di.languagePickerModule
@@ -46,7 +47,8 @@ val applicationModule = module {
longPollModule, longPollModule,
friendsModule, friendsModule,
profileModule, profileModule,
chatMaterialsModule chatMaterialsModule,
createChatModule
) )
// TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors // TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors
@@ -24,6 +24,8 @@ fun NavGraphBuilder.mainScreen(
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onConversationClicked: (conversationId: Int) -> Unit, onConversationClicked: (conversationId: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
onCreateChatClicked: () -> Unit,
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
@@ -54,6 +56,8 @@ fun NavGraphBuilder.mainScreen(
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onConversationItemClicked = onConversationClicked, onConversationItemClicked = onConversationClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
onCreateChatClicked = onCreateChatClicked,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -33,7 +33,7 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.MainViewModel import dev.meloda.fast.MainViewModel
@@ -47,8 +47,6 @@ import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@OptIn(ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
@@ -58,6 +56,8 @@ fun MainScreen(
onSettingsButtonClicked: () -> Unit = {}, onSettingsButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {},
onCreateChatClicked: () -> Unit = {},
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -70,21 +70,13 @@ fun MainScreen(
mutableIntStateOf(1) mutableIntStateOf(1)
} }
val sharedFlow = remember {
MutableSharedFlow<Int>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
Scaffold( Scaffold(
bottomBar = { bottomBar = {
NavigationBar( NavigationBar(
modifier = Modifier modifier = Modifier
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.hazeChild( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.thick() style = HazeMaterials.thick()
) )
@@ -108,8 +100,6 @@ fun MainScreen(
inclusive = true inclusive = true
} }
} }
} else {
sharedFlow.tryEmit(index)
} }
}, },
icon = { icon = {
@@ -176,13 +166,14 @@ fun MainScreen(
friendsScreen( friendsScreen(
onError = onError, onError = onError,
navController = navController, navController = navController,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
) )
conversationsScreen( conversationsScreen(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
scrollToTopFlow = sharedFlow, onCreateChatClicked = onCreateChatClicked,
navController = navController, navController = navController,
) )
profileScreen( profileScreen(
@@ -25,6 +25,8 @@ import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
@@ -124,6 +126,8 @@ fun RootScreen(
onSettingsButtonClicked = navController::navigateToSettings, onSettingsButtonClicked = navController::navigateToSettings,
onConversationClicked = navController::navigateToMessagesHistory, onConversationClicked = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }, onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
onMessageClicked = navController::navigateToMessagesHistory,
onCreateChatClicked = navController::navigateToCreateChat,
viewModel = viewModel viewModel = viewModel
) )
@@ -136,6 +140,13 @@ fun RootScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
) )
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen( settingsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
@@ -1,10 +1,10 @@
package dev.meloda.fast.data.api.messages package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface MessagesRepository { interface MessagesRepository {
@@ -41,6 +41,11 @@ interface MessagesRepository {
conversationMessageId: Int conversationMessageId: Int
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain> ): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain>
suspend fun createChat(
userIds: List<Int>?,
title: String?
): ApiResult<Int, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -1,5 +1,6 @@
package dev.meloda.fast.data.api.messages package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
@@ -14,6 +15,7 @@ import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
@@ -23,7 +25,6 @@ import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.messages.MessagesService import dev.meloda.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -198,6 +199,23 @@ class MessagesRepositoryImpl(
) )
} }
override suspend fun createChat(
userIds: List<Int>?,
title: String?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesCreateChatRequest(
userIds = userIds,
title = title
)
messagesService.createChat(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().chatId
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun storeMessages(messages: List<VkMessage>) { override suspend fun storeMessages(messages: List<VkMessage>) {
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
@@ -20,4 +20,8 @@ class GetLocalUserByIdUseCase(private val repository: UsersRepository) {
emit(newState) emit(newState)
} }
suspend fun proceed(userId: Int): VkUser? {
return repository.getLocalUsers(userIds = listOf(userId)).singleOrNull()
}
} }
@@ -41,15 +41,9 @@ class LongPollUpdatesParser(
fun parseNextUpdate(event: List<Any>) { fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt() val eventId = event.first().asInt()
val eventType: ApiEvent = try { when (val eventType = ApiEvent.parseOrNull(eventId)) {
ApiEvent.parse(eventId) null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
} catch (e: Exception) {
e.printStackTrace()
Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
return
}
when (eventType) {
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event) ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
@@ -42,6 +42,11 @@ interface MessagesUseCase {
conversationMessageId: Int conversationMessageId: Int
): Flow<State<List<VkAttachmentHistoryMessage>>> ): Flow<State<List<VkAttachmentHistoryMessage>>>
fun createChat(
userIds: List<Int>?,
title: String?
): Flow<State<Int>>
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
} }
@@ -100,6 +100,14 @@ class MessagesUseCaseImpl(
emit(newState) emit(newState)
} }
override fun createChat(userIds: List<Int>?, title: String?): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.createChat(userIds, title).mapToState()
emit(newState)
}
override suspend fun storeMessage(message: VkMessage) { override suspend fun storeMessage(message: VkMessage) {
repository.storeMessages(listOf(message)) repository.storeMessages(listOf(message))
} }
@@ -1,9 +1,9 @@
package dev.meloda.fast.friends.util package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.api.UiFriend
fun VkUser.asPresentation( fun VkUser.asPresentation(
useContactNames: Boolean = false useContactNames: Boolean = false
@@ -16,5 +16,7 @@ fun VkUser.asPresentation(
fullName fullName
}, },
onlineStatus = onlineStatus, onlineStatus = onlineStatus,
photo400Orig = photo400Orig?.let(UiImage::Url) photo400Orig = photo400Orig?.let(UiImage::Url),
firstName = firstName,
lastName = lastName
) )
@@ -18,5 +18,6 @@ enum class ApiEvent(val value: Int) {
companion object { companion object {
fun parse(value: Int) = entries.first { it.value == value } fun parse(value: Int) = entries.first { it.value == value }
fun parseOrNull(value: Int) = entries.firstOrNull { it.value == value }
} }
} }
@@ -38,6 +38,41 @@ data class VkConversation(
fun isPinned(): Boolean = majorId > 0 fun isPinned(): Boolean = majorId > 0
fun isInUnread() = inRead - (lastMessageId ?: 0) < 0 fun isInUnread() = inRead - (lastMessageId ?: 0) < 0
fun isOutUnread() = outRead - (lastMessageId ?: 0) < 0 fun isOutUnread() = outRead - (lastMessageId ?: 0) < 0
companion object {
val EMPTY: VkConversation = VkConversation(
id = -1,
localId = -1,
ownerId = null,
title = "...",
photo50 = null,
photo100 = null,
photo200 = null,
isCallInProgress = false,
isPhantom = false,
lastConversationMessageId = -1,
inReadCmId = -1,
outReadCmId = -1,
inRead = -1,
outRead = -1,
lastMessageId = null,
unreadCount = -1,
membersCount = null,
canChangePin = false,
canChangeInfo = false,
majorId = -1,
minorId = -1,
pinnedMessageId = null,
interactionType = -1,
interactionIds = emptyList(),
peerType = PeerType.USER,
lastMessage = null,
pinnedMessage = null,
user = null,
group = null
)
}
} }
fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity( fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity(
@@ -38,11 +38,10 @@ data class VkMessage(
fun isGroup() = fromId < 0 fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation) = fun isRead(conversation: VkConversation): Boolean = when {
if (isOut) { id <= 0 -> false
conversation.outRead - id >= 0 isOut -> conversation.outRead - id >= 0
} else { else -> conversation.inRead - id >= 0
conversation.inRead - id >= 0
} }
fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty() fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty()
@@ -267,3 +267,14 @@ data class MessagesGetHistoryAttachmentsRequest(
fields?.let { this["fields"] = it } fields?.let { this["fields"] = it }
} }
} }
data class MessagesCreateChatRequest(
val userIds: List<Int>?,
val title: String?
) {
val map = mutableMapOf<String, String>().apply {
userIds?.let { this["user_ids"] = it.joinToString(",") }
title?.let { this["title"] = it }
}
}
@@ -1,5 +1,7 @@
package dev.meloda.fast.model.api.responses package dev.meloda.fast.model.api.responses
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData
import dev.meloda.fast.model.api.data.VkChatMemberData import dev.meloda.fast.model.api.data.VkChatMemberData
import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkContactData
@@ -7,8 +9,6 @@ import dev.meloda.fast.model.api.data.VkConversationData
import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class MessagesGetHistoryResponse( data class MessagesGetHistoryResponse(
@@ -44,3 +44,9 @@ data class MessagesGetHistoryAttachmentsResponse(
@Json(name = "groups") val groups: List<VkGroupData>?, @Json(name = "groups") val groups: List<VkGroupData>?,
@Json(name = "contacts") val contacts: List<VkContactData>? @Json(name = "contacts") val contacts: List<VkContactData>?
) )
@JsonClass(generateAdapter = true)
data class MessagesCreateChatResponse(
@Json(name = "chat_id") val chatId: Int,
@Json(name = "peer_ids") val peerIds: List<Int>
)
@@ -43,6 +43,12 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
converter.fromJson(successType, string) converter.fromJson(successType, string)
}.fold( }.fold(
onSuccess = { successModel -> onSuccess = { successModel ->
if (successModel is ApiResponse<*>) {
if (successModel.error != null) {
throw ApiException(successModel.error)
}
}
return successModel return successModel
}, },
onFailure = { failure -> onFailure = { failure ->
@@ -42,6 +42,8 @@ enum class VkErrorCode(val code: Int) {
ACCESS_TO_DOC_DENIED(1153), ACCESS_TO_DOC_DENIED(1153),
SOME_AUTH_ERROR(104), SOME_AUTH_ERROR(104),
CANNOT_SEND_MESSAGE_DUE_TO_PRIVACY_SETTINGS(902),
ACCESS_TOKEN_EXPIRED(1117); ACCESS_TOKEN_EXPIRED(1117);
companion object { companion object {
@@ -1,12 +1,13 @@
package dev.meloda.fast.network.service.messages package dev.meloda.fast.network.service.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError import dev.meloda.fast.network.RestApiError
import com.slack.eithernet.ApiResult
import retrofit2.http.FieldMap import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST import retrofit2.http.POST
@@ -49,6 +50,12 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError> ): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.CREATE_CHAT)
suspend fun createChat(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesCreateChatResponse>, RestApiError>
// @FormUrlEncoded // @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant) // @POST(MessagesUrls.MarkAsImportant)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -19,4 +19,5 @@ object MessagesUrls {
const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers" const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers"
const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser" const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments" const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments"
const val CREATE_CHAT = "${AppConstants.URL_API}/messages.createChat"
} }
+1
View File
@@ -0,0 +1 @@
/build
+12
View File
@@ -0,0 +1,12 @@
plugins {
alias(libs.plugins.fast.android.library)
alias(libs.plugins.fast.android.library.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "dev.meloda.fast.presentation"
}
dependencies {
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
+1
View File
@@ -11,6 +11,7 @@ android {
dependencies { dependencies {
api(projects.core.common) api(projects.core.common)
api(projects.core.model) api(projects.core.model)
implementation(projects.core.presentation)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
@@ -12,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -31,7 +32,8 @@ fun ErrorView(
) { ) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
) )
buttonText?.let { buttonText?.let {
@@ -29,9 +29,9 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun IconButton( fun IconButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
@@ -1,29 +1,51 @@
package dev.meloda.fast.ui.components package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@Composable @Composable
fun NoItemsView( fun NoItemsView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
customText: String? = null customText: String? = null,
buttonText: String? = null,
onButtonClick: (() -> Unit)? = null,
) { ) {
Box( Column(
modifier = modifier.fillMaxSize(), modifier = modifier
contentAlignment = Alignment.Center .fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = customText ?: stringResource(id = R.string.no_items), text = customText ?: stringResource(R.string.no_items),
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
) )
buttonText?.let {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { onButtonClick?.invoke() }
) {
Text(text = buttonText)
}
}
} }
} }
@@ -31,6 +53,7 @@ fun NoItemsView(
@Composable @Composable
private fun NoItemsViewPreview() { private fun NoItemsViewPreview() {
NoItemsView( NoItemsView(
customText = "Nothing here..." customText = "Nothing here...",
buttonText = "Refresh"
) )
} }
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
enum class ActionState { enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE; PHANTOM, CALL_IN_PROGRESS, NONE;
@@ -0,0 +1,31 @@
package dev.meloda.fast.ui.model.api
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.R
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(R.string.action_mark_as_read),
icon = UiImage.Resource(R.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(R.string.action_pin),
icon = UiImage.Resource(R.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(R.string.action_unpin),
icon = UiImage.Resource(R.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(R.string.action_delete),
icon = UiImage.Resource(R.drawable.round_delete_outline_24)
)
}
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
data class ConversationsShowOptions( data class ConversationsShowOptions(
val showDeleteDialog: Int?, val showDeleteDialog: Int?,
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -1,11 +1,15 @@
package dev.meloda.fast.friends.model package dev.meloda.fast.ui.model.api
import androidx.compose.runtime.Immutable
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.OnlineStatus import dev.meloda.fast.model.api.domain.OnlineStatus
@Immutable
data class UiFriend( data class UiFriend(
val userId: Int, val userId: Int,
val avatar: UiImage?, val avatar: UiImage?,
val firstName: String,
val lastName: String,
val title: String, val title: String,
val onlineStatus: OnlineStatus, val onlineStatus: OnlineStatus,
val photo400Orig: UiImage? val photo400Orig: UiImage?
@@ -1,7 +1,6 @@
package dev.meloda.fast.ui.util package dev.meloda.fast.ui.util
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.drawable.ColorDrawable
import android.os.PowerManager import android.os.PowerManager
import android.view.KeyEvent import android.view.KeyEvent
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@@ -21,6 +20,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toDrawable
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
@@ -64,8 +64,8 @@ fun UiImage.getResourcePainter(): Painter? {
@Composable @Composable
fun UiImage.getImage(): Any { fun UiImage.getImage(): Any {
return when (this) { return when (this) {
is UiImage.Color -> ColorDrawable(color) is UiImage.Color -> color.toDrawable()
is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb()) is UiImage.ColorResource -> colorResource(id = resId).toArgb().toDrawable()
is UiImage.Resource -> painterResource(id = resId) is UiImage.Resource -> painterResource(id = resId)
is UiImage.Simple -> drawable is UiImage.Simple -> drawable
is UiImage.Url -> url is UiImage.Url -> url
@@ -1,5 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,6c1.93,0 3.5,1.57 3.5,3.5S13.93,13 12,13s-3.5,-1.57 -3.5,-3.5S10.07,6 12,6zM12,20c-2.03,0 -4.43,-0.82 -6.14,-2.88C7.55,15.8 9.68,15 12,15s4.45,0.8 6.14,2.12C16.43,19.18 14.03,20 12,20z"/> <path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,6c1.93,0 3.5,1.57 3.5,3.5S13.93,13 12,13s-3.5,-1.57 -3.5,-3.5S10.07,6 12,6zM12,20c-2.03,0 -4.43,-0.82 -6.14,-2.88C7.55,15.8 9.68,15 12,15s4.45,0.8 6.14,2.12C16.43,19.18 14.03,20 12,20z" />
</vector> </vector>
@@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z"/> <path
android:fillColor="@android:color/white"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z" />
</vector> </vector>
@@ -1,11 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z"/> <path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z" />
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M9,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0"/> <path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M9,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z"/> <path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z" />
<path android:fillColor="@android:color/white" android:fillType="evenOdd" android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13z"/> <path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13z" />
</vector> </vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c-0.55,0 -1,0.45 -1,1v3L8,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h3v3c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-3h3c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-3L13,8c0,-0.55 -0.45,-1 -1,-1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.3,5.71c-0.39,-0.39 -1.02,-0.39 -1.41,0L12,10.59 7.11,5.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41L10.59,12 5.7,16.89c-0.39,0.39 -0.39,1.02 0,1.41 0.39,0.39 1.02,0.39 1.41,0L12,13.41l4.89,4.89c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L13.41,12l4.89,-4.89c0.38,-0.38 0.38,-1.02 0,-1.4z" />
</vector>
@@ -1,7 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z"/> <path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z" />
<path android:fillColor="@android:color/white" android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z"/> <path
android:fillColor="@android:color/white"
android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z" />
</vector> </vector>
@@ -1,5 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M4,4h16v12L5.17,16L4,17.17L4,4m0,-2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2L4,2zM6,12h8v2L6,14v-2zM6,9h12v2L6,11L6,9zM6,6h12v2L6,8L6,6z"/> <path
android:fillColor="@android:color/white"
android:pathData="M4,4h16v12L5.17,16L4,17.17L4,4m0,-2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2L4,2zM6,12h8v2L6,14v-2zM6,9h12v2L6,11L6,9zM6,6h12v2L6,8L6,6z" />
</vector> </vector>
@@ -1,11 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z"/> <path
android:fillColor="@android:color/white"
android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z" />
<path android:fillColor="@android:color/white" android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z"/> <path
android:fillColor="@android:color/white"
android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z" />
<path android:fillColor="@android:color/white" android:pathData="M9,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8C5,10.21 6.79,12 9,12zM9,6c1.1,0 2,0.9 2,2c0,1.1 -0.9,2 -2,2S7,9.1 7,8C7,6.9 7.9,6 9,6z"/> <path
android:fillColor="@android:color/white"
android:pathData="M9,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8C5,10.21 6.79,12 9,12zM9,6c1.1,0 2,0.9 2,2c0,1.1 -0.9,2 -2,2S7,9.1 7,8C7,6.9 7.9,6 9,6z" />
<path android:fillColor="@android:color/white" android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13zM15,18H3l0,-0.99C3.2,16.29 6.3,15 9,15s5.8,1.29 6,2V18z"/> <path
android:fillColor="@android:color/white"
android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13zM15,18H3l0,-0.99C3.2,16.29 6.3,15 9,15s5.8,1.29 6,2V18z" />
</vector> </vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM11.78,7h-0.06c-0.4,0 -0.72,0.32 -0.72,0.72v4.72c0,0.35 0.18,0.68 0.49,0.86l4.15,2.49c0.34,0.2 0.78,0.1 0.98,-0.24 0.21,-0.34 0.1,-0.79 -0.25,-0.99l-3.87,-2.3L12.5,7.72c0,-0.4 -0.32,-0.72 -0.72,-0.72z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c0.55,0 1,0.45 1,1v4c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1L11,8c0,-0.55 0.45,-1 1,-1zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM13,17h-2v-2h2v2z" />
</vector>
+7 -1
View File
@@ -128,7 +128,7 @@
<string name="post_type_community">Запись сообщества</string> <string name="post_type_community">Запись сообщества</string>
<string name="post_type_user">Запись пользователя</string> <string name="post_type_user">Запись пользователя</string>
<string name="post_type_unknown">Запись на стене</string> <string name="post_type_unknown">Запись на стене</string>
<string name="log_out">Выйти</string> <string name="action_log_out">Выйти</string>
<string name="confirm">Подтверждение</string> <string name="confirm">Подтверждение</string>
<string name="message_attachment_story_your_story">Ваша история</string> <string name="message_attachment_story_your_story">Ваша история</string>
<string name="settings_dynamic_colors">Динамические цвета</string> <string name="settings_dynamic_colors">Динамические цвета</string>
@@ -212,4 +212,10 @@
<string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string> <string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string>
<string name="validation_exit_warning">Вы уверены? Процесс ввода кода-подтверждения будет отменён</string> <string name="validation_exit_warning">Вы уверены? Процесс ввода кода-подтверждения будет отменён</string>
<string name="action_authorize">Авторизоваться</string> <string name="action_authorize">Авторизоваться</string>
<string name="no_online_friends">Никого в сети</string>
<string name="try_again">Попробовать ещё раз</string>
<string name="session_expired">Срок действия сессии истёк</string>
<string name="title_create_chat">Создать чат</string>
<string name="action_create">Создать</string>
<string name="create_chat_title">Название</string>
</resources> </resources>
+7 -2
View File
@@ -119,7 +119,7 @@
<string name="post_type_community">Community post</string> <string name="post_type_community">Community post</string>
<string name="post_type_user">User post</string> <string name="post_type_user">User post</string>
<string name="post_type_unknown">Post</string> <string name="post_type_unknown">Post</string>
<string name="log_out">Log out</string> <string name="action_log_out">Log out</string>
<string name="confirm">Confirmation</string> <string name="confirm">Confirmation</string>
<string name="sign_out_confirm">Signing out will delete all data related to this account from this device. Continue?</string> <string name="sign_out_confirm">Signing out will delete all data related to this account from this device. Continue?</string>
<string name="yes">Yes</string> <string name="yes">Yes</string>
@@ -276,6 +276,11 @@
<string name="warning_confirmation">Confirmation</string> <string name="warning_confirmation">Confirmation</string>
<string name="captcha_exit_warning">Are you sure? Captcha process will be cancelled</string> <string name="captcha_exit_warning">Are you sure? Captcha process will be cancelled</string>
<string name="validation_exit_warning">Are you sure? Validation process will be cancelled</string> <string name="validation_exit_warning">Are you sure? Validation process will be cancelled</string>
<string name="settings_general_enable_pull_to_refresh_title">Enable pull to refresh</string>
<string name="action_authorize">Authorize</string> <string name="action_authorize">Authorize</string>
<string name="no_online_friends">No one is online</string>
<string name="try_again">Try again</string>
<string name="session_expired">Session expired</string>
<string name="title_create_chat">Create chat</string>
<string name="action_create">Create</string>
<string name="create_chat_title">Title</string>
</resources> </resources>
@@ -11,10 +11,7 @@ import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.ConversationsShowOptions
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.conversations.util.asPresentation import dev.meloda.fast.conversations.util.asPresentation
import dev.meloda.fast.conversations.util.extractAvatar import dev.meloda.fast.conversations.util.extractAvatar
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
@@ -29,10 +26,11 @@ import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -40,7 +38,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
interface ConversationsViewModel { interface ConversationsViewModel {
@@ -49,7 +46,6 @@ interface ConversationsViewModel {
val baseError: StateFlow<BaseError?> val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int> val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
val scrollToTop: StateFlow<Boolean>
fun onPaginationConditionsMet() fun onPaginationConditionsMet()
@@ -70,10 +66,6 @@ interface ConversationsViewModel {
fun setScrollIndex(index: Int) fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int) fun setScrollOffset(offset: Int)
fun setScrollToTopFlow(scrollToTopFlow: Flow<Int>)
fun onScrolledToTop()
} }
class ConversationsViewModelImpl( class ConversationsViewModelImpl(
@@ -91,12 +83,8 @@ class ConversationsViewModelImpl(
override val baseError = MutableStateFlow<BaseError?>(null) override val baseError = MutableStateFlow<BaseError?>(null)
override val currentOffset = MutableStateFlow(0) override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) override val canPaginate = MutableStateFlow(false)
override val scrollToTop = MutableStateFlow(false)
// TODO: 22-Dec-24, Danil Nikolaev: rewrite private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val useContactNames = {
userSettings.useContactNames.value
}
override fun onPaginationConditionsMet() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size } currentOffset.update { screenState.value.conversations.size }
@@ -134,7 +122,7 @@ class ConversationsViewModelImpl(
} }
override fun onRefresh() { override fun onRefresh() {
baseError.setValue { null } onErrorConsumed()
loadConversations(offset = 0) loadConversations(offset = 0)
} }
@@ -200,7 +188,10 @@ class ConversationsViewModelImpl(
onPinDialogDismissed() onPinDialogDismissed()
} }
override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) { override fun onOptionClicked(
conversation: UiConversation,
option: ConversationOption
) {
when (option) { when (option) {
ConversationOption.Delete -> { ConversationOption.Delete -> {
emitShowOptions { old -> emitShowOptions { old ->
@@ -237,20 +228,6 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> old.copy(scrollOffset = offset) } screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
override fun setScrollToTopFlow(scrollToTopFlow: Flow<Int>) {
scrollToTopFlow.listenValue(viewModelScope) { index ->
if (index == 1) {
scrollToTop.emit(true)
}
}
}
override fun onScrolledToTop() {
viewModelScope.launch(Dispatchers.Main) {
scrollToTop.emit(false)
}
}
private fun hideOptions(conversationId: Int) { private fun hideOptions(conversationId: Int) {
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
@@ -345,21 +322,25 @@ class ConversationsViewModelImpl(
} }
} }
} }
State.Error.ConnectionError -> { State.Error.ConnectionError -> {
baseError.setValue { baseError.setValue {
BaseError.SimpleError(message = "Connection error") BaseError.SimpleError(message = "Connection error")
} }
} }
State.Error.InternalError -> { State.Error.InternalError -> {
baseError.setValue { baseError.setValue {
BaseError.SimpleError(message = "Internal error") BaseError.SimpleError(message = "Internal error")
} }
} }
State.Error.UnknownError -> { State.Error.UnknownError -> {
baseError.setValue { baseError.setValue {
BaseError.SimpleError(message = "Unknown error") BaseError.SimpleError(message = "Unknown error")
} }
} }
else -> Unit else -> Unit
} }
} }
@@ -383,7 +364,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -447,7 +428,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -498,7 +479,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -527,7 +508,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -557,7 +538,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -586,7 +567,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -632,7 +613,7 @@ class ConversationsViewModelImpl(
old.copy(conversations = newConversations.map { old.copy(conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
}) })
} }
@@ -673,7 +654,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -724,7 +705,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -758,7 +739,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -1,31 +0,0 @@
package dev.meloda.fast.conversations.model
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.R as UiR
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(UiR.string.action_mark_as_read),
icon = UiImage.Resource(UiR.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(UiR.string.action_pin),
icon = UiImage.Resource(UiR.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(UiR.string.action_unpin),
icon = UiImage.Resource(UiR.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(UiR.string.action_delete),
icon = UiImage.Resource(UiR.drawable.round_delete_outline_24)
)
}
@@ -1,6 +1,8 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
@Immutable @Immutable
data class ConversationsScreenState( data class ConversationsScreenState(
@@ -8,7 +8,6 @@ import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.presentation.ConversationsRoute import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.extensions.sharedViewModel import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -18,18 +17,18 @@ fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit, onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
scrollToTopFlow: Flow<Int>, onCreateChatClicked: () -> Unit,
navController: NavController, navController: NavController,
) { ) {
composable<Conversations> { composable<Conversations> {
val viewModel: ConversationsViewModel = val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController) it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
viewModel.setScrollToTopFlow(scrollToTopFlow)
ConversationsRoute( ConversationsRoute(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked, onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -48,11 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.getImage import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString import dev.meloda.fast.ui.util.getString
@@ -256,7 +256,7 @@ fun ConversationItem(
Row { Row {
if (conversation.interactionText != null) { if (conversation.interactionText != null) {
Text( Text(
text = conversation.interactionText, text = conversation.interactionText.orEmpty(),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -23,10 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -83,8 +82,7 @@ fun ConversationsList(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null) .animateItem(fadeInSpec = null, fadeOutSpec = null),
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (screenState.isPaginating) { if (screenState.isPaginating) {
@@ -107,11 +105,9 @@ fun ConversationsList(
) )
} }
} }
}
}
item { Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(bottomPadding)) }
} }
} }
} }
@@ -2,7 +2,6 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -48,12 +47,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -62,28 +59,26 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.ConversationsViewModel import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@Composable @Composable
@@ -91,12 +86,12 @@ fun ConversationsRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit, onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit, onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel viewModel: ConversationsViewModel
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle()
ConversationsScreen( ConversationsScreen(
screenState = screenState, screenState = screenState,
@@ -113,10 +108,9 @@ fun ConversationsRoute(
onRefreshDropdownItemClicked = viewModel::onRefresh, onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked, onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex, setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset, setScrollOffset = viewModel::setScrollOffset
isNeedToScrollToTop = isNeedToScrollToTop,
onScrolledToTop = viewModel::onScrolledToTop
) )
HandleDialogs( HandleDialogs(
@@ -134,7 +128,7 @@ fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY, screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null, baseError: BaseError? = null,
canPaginate: Boolean = false, canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit, onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {}, onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> }, onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
@@ -142,10 +136,9 @@ fun ConversationsScreen(
onRefreshDropdownItemClicked: () -> Unit = {}, onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {}, onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}, setScrollOffset: (Int) -> Unit = {}
isNeedToScrollToTop: Boolean = false,
onScrolledToTop: () -> Unit = {}
) { ) {
val view = LocalView.current val view = LocalView.current
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -159,14 +152,6 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset initialFirstVisibleItemScrollOffset = screenState.scrollOffset
) )
LaunchedEffect(isNeedToScrollToTop) {
if (isNeedToScrollToTop) {
listState.scrollToItem(0)
onScrolledToTop()
}
}
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L) .debounce(500L)
@@ -275,7 +260,7 @@ fun ConversationsScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.hazeChild( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.thick() style = HazeMaterials.thick()
) )
@@ -296,37 +281,13 @@ fun ConversationsScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Column { Column {
AnimatedVisibility( AnimatedVisibility(
visible = listState.isScrollingUp(), visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)), enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200)) exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) { ) {
FloatingActionButton( FloatingActionButton(onClick = onCreateChatButtonClicked) {
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
modifier = Modifier.rotate(rotation.value)
) {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24), painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button" contentDescription = "Add chat button"
@@ -343,8 +304,8 @@ fun ConversationsScreen(
when (baseError) { when (baseError) {
is BaseError.SessionExpired -> { is BaseError.SessionExpired -> {
ErrorView( ErrorView(
text = "Session expired", text = stringResource(UiR.string.session_expired),
buttonText = "Log out", buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked onButtonClick = onSessionExpiredLogOutButtonClicked
) )
} }
@@ -352,7 +313,7 @@ fun ConversationsScreen(
is BaseError.SimpleError -> { is BaseError.SimpleError -> {
ErrorView( ErrorView(
text = baseError.message, text = baseError.message,
buttonText = "Try again", buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh onButtonClick = onRefresh
) )
} }
@@ -390,7 +351,7 @@ fun ConversationsScreen(
state = listState, state = listState,
maxLines = maxLines, maxLines = maxLines,
modifier = if (currentTheme.enableBlur) { modifier = if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState) Modifier.hazeSource(state = hazeState)
} else { } else {
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
@@ -398,6 +359,13 @@ fun ConversationsScreen(
padding = padding, padding = padding,
onPhotoClicked = onConversationPhotoClicked onPhotoClicked = onConversationPhotoClicked
) )
if (screenState.conversations.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
} }
} }
} }
@@ -422,9 +390,7 @@ fun HandleDialogs(
) )
} }
if (showOptions.showPinDialog != null) { showOptions.showPinDialog?.let { conversation ->
val conversation = showOptions.showPinDialog
MaterialDialog( MaterialDialog(
onDismissRequest = viewModel::onPinDialogDismissed, onDismissRequest = viewModel::onPinDialogDismissed,
title = stringResource( title = stringResource(
@@ -14,8 +14,6 @@ import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.util.TimeUtils import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.conversations.model.ActionState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
@@ -24,6 +22,8 @@ import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
+1
View File
@@ -0,0 +1 @@
/build
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.fast.android.feature)
alias(libs.plugins.fast.android.library.compose)
}
android {
namespace = "dev.meloda.fast.createchat"
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.bundles.nanokt)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
@@ -0,0 +1,243 @@
package dev.meloda.fast.conversations
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.api.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
interface CreateChatViewModel {
val screenState: StateFlow<CreateChatScreenState>
val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
val isChatCreated: StateFlow<Int?>
fun onPaginationConditionsMet()
fun onRefresh()
fun onErrorConsumed()
fun toggleFriendSelection(userId: Int)
fun onTitleTextInputChanged(newTitle: String)
fun onCreateChatButtonClicked()
fun onNavigatedBack()
}
class CreateChatViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val messagesUseCase: MessagesUseCase,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
private val userSettings: UserSettings
) : CreateChatViewModel, ViewModel() {
override val screenState = MutableStateFlow(CreateChatScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override val isChatCreated = MutableStateFlow<Int?>(null)
private val useContactNames: Boolean = userSettings.useContactNames.value
init {
loadFriends()
}
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size }
loadFriends()
}
override fun onRefresh() {
onErrorConsumed()
loadFriends(offset = 0)
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
override fun toggleFriendSelection(userId: Int) {
val newSelectionList = screenState.value.selectedFriendsIds.toMutableList()
if (newSelectionList.contains(userId)) {
newSelectionList.remove(userId)
} else {
newSelectionList.add(userId)
}
screenState.setValue { old ->
old.copy(selectedFriendsIds = newSelectionList)
}
}
override fun onTitleTextInputChanged(newTitle: String) {
screenState.setValue { old -> old.copy(chatTitle = newTitle) }
}
override fun onCreateChatButtonClicked() {
createChat()
}
override fun onNavigatedBack() {
viewModelScope.launch(Dispatchers.Main) {
isChatCreated.emit(null)
}
}
private fun loadFriends(
offset: Int = currentOffset.value
) {
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.friends.isNotEmpty()
val imagesToPreload =
response.mapNotNull { it.photo100.takeIf { !it.isNullOrEmpty() } }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(useContactNames)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
screenState.setValue {
newState.copy(friends = loadedFriends)
}
} else {
screenState.setValue {
newState.copy(
friends = newState.friends.plus(loadedFriends)
)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun createChat() {
viewModelScope.launch {
val title = screenState.value.chatTitle.takeUnless(String::isBlank)
val accountAsFriend =
getLocalUserByIdUseCase.proceed(UserConfig.userId)?.asPresentation(useContactNames)
val accountList = accountAsFriend?.let(::listOf) ?: emptyList()
val selectedFriends = screenState.value.selectedFriendsIds
.takeIf { it.isNotEmpty() }
?.mapNotNull { userId -> screenState.value.friends.find { it.userId == userId } }
messagesUseCase.createChat(
userIds = selectedFriends?.map { it.userId },
title = title
?: (accountList + selectedFriends.orEmpty()).joinToString(transform = UiFriend::firstName)
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
withContext(Dispatchers.Main) {
isChatCreated.emit(2_000_000_000 + response)
}
}
)
}
}
}
private fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
companion object {
const val LOAD_COUNT = 30
}
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.CreateChatViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val createChatModule = module {
viewModelOf(::CreateChatViewModelImpl)
}
@@ -0,0 +1,25 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable
data class CreateChatScreenState(
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val friends: List<UiFriend>,
val selectedFriendsIds: List<Int>,
val chatTitle: String
) {
companion object {
val EMPTY: CreateChatScreenState = CreateChatScreenState(
isLoading = true,
isPaginating = false,
isPaginationExhausted = false,
friends = emptyList(),
selectedFriendsIds = emptyList(),
chatTitle = ""
)
}
}
@@ -0,0 +1,36 @@
package dev.meloda.fast.conversations.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.CreateChatViewModelImpl
import dev.meloda.fast.conversations.presentation.CreateChatRoute
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable
@Serializable
object CreateChat
fun NavGraphBuilder.createChatScreen(
onChatCreated: (Int) -> Unit,
navController: NavController,
) {
composable<CreateChat> {
val viewModel: CreateChatViewModel =
it.sharedViewModel<CreateChatViewModelImpl>(navController = navController)
CreateChatRoute(
onError = {
},
onBack = navController::popBackStack,
onChatCreated = onChatCreated,
viewModel = viewModel
)
}
}
fun NavController.navigateToCreateChat() {
this.navigate(CreateChat)
}
@@ -0,0 +1,110 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable
fun CreateChatItem(
modifier: Modifier = Modifier,
friend: UiFriend,
maxLines: Int,
isSelected: Boolean,
onItemClicked: (Int) -> Unit
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onItemClicked(friend.userId) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(16.dp))
val friendAvatar = friend.avatar?.extractUrl()
Box(modifier = Modifier.size(56.dp)) {
if (friendAvatar == null) {
Image(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
painter = painterResource(id = R.drawable.ic_account_circle_cut),
contentDescription = "Avatar",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
} else {
AsyncImage(
model = friendAvatar,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
)
}
if (friend.onlineStatus.isOnline()) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(18.dp)
.background(MaterialTheme.colorScheme.background)
.padding(2.dp)
.align(Alignment.BottomEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(MaterialTheme.colorScheme.primary)
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = friend.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(16.dp))
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClicked(friend.userId) },
)
Spacer(modifier = Modifier.width(16.dp))
}
}
@@ -0,0 +1,101 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CreateChatList(
screenState: CreateChatScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
padding: PaddingValues,
onItemClicked: (Int) -> Unit,
onTitleTextInputChanged: (String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
LazyColumn(
modifier = modifier,
state = state
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(
items = screenState.friends,
key = UiFriend::userId,
) { friend ->
CreateChatItem(
maxLines = maxLines,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
friend = friend,
isSelected = screenState.selectedFriendsIds.contains(friend.userId),
onItemClicked = onItemClicked
)
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
state.scrollToItem(14)
state.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
@@ -0,0 +1,342 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp
import dev.meloda.fast.ui.R as UiR
@Composable
fun CreateChatRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatCreated: (Int) -> Unit,
viewModel: CreateChatViewModel
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isChatCreated by viewModel.isChatCreated.collectAsStateWithLifecycle()
LaunchedEffect(isChatCreated) {
if (isChatCreated != null) {
onChatCreated(isChatCreated ?: -1)
viewModel.onNavigatedBack()
}
}
CreateChatScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onBack = onBack,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onItemClicked = viewModel::toggleFriendSelection,
onTitleTextInputChanged = viewModel::onTitleTextInputChanged
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun CreateChatScreen(
screenState: CreateChatScreenState = CreateChatScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onBack: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onItemClicked: (Int) -> Unit = {},
onTitleTextInputChanged: (String) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) {
mutableIntStateOf(if (currentTheme.enableMultiline) 2 else 1)
}
val listState = rememberLazyListState()
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
val hazeState = LocalHazeState.current
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
toolbarContainerColor.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
)
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
title = {
Text(
text = stringResource(
id = if (screenState.isLoading) UiR.string.title_loading
else UiR.string.title_create_chat
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = 0f
)
),
modifier = Modifier.fillMaxWidth(),
)
var isTextFieldFocused by remember {
mutableStateOf(false)
}
val borderWidth by animateDpAsState(if (isTextFieldFocused) 1.5.dp else 0.dp)
val borderColor by animateColorAsState(
if (isTextFieldFocused) MaterialTheme.colorScheme.primary
else Color.Transparent
)
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(
borderWidth,
borderColor,
RoundedCornerShape(16.dp)
)
.clip(RoundedCornerShape(16.dp))
.onFocusChanged { isTextFieldFocused = it.hasFocus },
value = screenState.chatTitle,
onValueChange = onTitleTextInputChanged,
label = { Text(text = stringResource(UiR.string.create_chat_title)) },
placeholder = { Text(text = stringResource(UiR.string.create_chat_title)) },
singleLine = true,
colors = TextFieldDefaults.colors(
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
)
)
Spacer(Modifier.height(16.dp))
}
},
floatingActionButton = {
if (baseError == null) {
Column(
modifier = Modifier
.imePadding()
.navigationBarsPadding()
) {
ExtendedFloatingActionButton(
onClick = onCreateChatButtonClicked,
expanded = listState.isScrollingUp(),
text = { Text(text = stringResource(UiR.string.action_create)) },
icon = {
Icon(
imageVector = Icons.Rounded.Done,
contentDescription = null
)
}
)
}
}
}
) { padding ->
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(UiR.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
}
}
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
CreateChatList(
screenState = screenState,
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
padding = padding,
onItemClicked = onItemClicked,
onTitleTextInputChanged = onTitleTextInputChanged
)
if (screenState.friends.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
}
@@ -9,8 +9,8 @@ import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.util.asPresentation
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
@@ -68,6 +68,7 @@ class FriendsViewModelImpl(
} }
override fun onRefresh() { override fun onRefresh() {
onErrorConsumed()
loadFriends(offset = 0) loadFriends(offset = 0)
} }
@@ -99,32 +100,12 @@ class FriendsViewModelImpl(
friendsUseCase.getOnlineFriends(null, null) friendsUseCase.getOnlineFriends(null, null)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = ::handleError,
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
success = { userIds -> success = { userIds ->
loadUsersByIdsUseCase(userIds = userIds) loadUsersByIdsUseCase(userIds = userIds)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = ::handleError,
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
success = { onlineFriends -> success = { onlineFriends ->
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
@@ -142,17 +123,7 @@ class FriendsViewModelImpl(
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset) friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = ::handleError,
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
success = { response -> success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
@@ -197,6 +168,40 @@ class FriendsViewModelImpl(
} }
} }
private fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
private fun updateFriendsNames(useContactNames: Boolean) { private fun updateFriendsNames(useContactNames: Boolean) {
val friends = friends.value val friends = friends.value
if (friends.isEmpty()) return if (friends.isEmpty()) return
@@ -1,6 +1,7 @@
package dev.meloda.fast.friends.model package dev.meloda.fast.friends.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable @Immutable
data class FriendsScreenState( data class FriendsScreenState(
@@ -16,7 +16,8 @@ object Friends
fun NavGraphBuilder.friendsScreen( fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
navController: NavController, navController: NavController,
onPhotoClicked: (url: String) -> Unit onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
) { ) {
composable<Friends> { composable<Friends> {
val viewModel: FriendsViewModel = val viewModel: FriendsViewModel =
@@ -25,7 +26,8 @@ fun NavGraphBuilder.friendsScreen(
FriendsRoute( FriendsRoute(
onError = onError, onError = onError,
viewModel = viewModel, viewModel = viewModel,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
) )
} }
} }
@@ -12,6 +12,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MailOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -23,15 +27,16 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable @Composable
fun FriendItem( fun FriendItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
friend: UiFriend, friend: UiFriend,
maxLines: Int, maxLines: Int,
onPhotoClicked: (url: String) -> Unit onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -92,9 +97,24 @@ fun FriendItem(
text = friend.title, text = friend.title,
minLines = 1, minLines = 1,
maxLines = maxLines, maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp) style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
modifier = Modifier.weight(1f)
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = {
onMessageClicked(friend.userId)
}
) {
Icon(
imageVector = Icons.Rounded.MailOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(16.dp))
} }
} }
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -23,8 +22,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.model.UiFriend import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -38,6 +36,7 @@ fun FriendsList(
maxLines: Int, maxLines: Int,
padding: PaddingValues, padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
setCanScrollBackward: (Boolean) -> Unit setCanScrollBackward: (Boolean) -> Unit
) { ) {
LaunchedEffect(listState) { LaunchedEffect(listState) {
@@ -49,8 +48,6 @@ fun FriendsList(
val friends = uiFriends.toList() val friends = uiFriends.toList()
val bottomPadding = LocalBottomPadding.current
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState state = listState
@@ -67,7 +64,8 @@ fun FriendsList(
FriendItem( FriendItem(
friend = friend, friend = friend,
maxLines = maxLines, maxLines = maxLines,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -77,8 +75,7 @@ fun FriendsList(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null) .animateItem(fadeInSpec = null, fadeOutSpec = null),
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (screenState.isPaginating) { if (screenState.isPaginating) {
@@ -101,11 +98,9 @@ fun FriendsList(
) )
} }
} }
}
}
item { Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(bottomPadding)) }
} }
} }
} }
@@ -48,8 +48,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.friends.FriendsViewModel import dev.meloda.fast.friends.FriendsViewModel
@@ -72,6 +72,7 @@ import dev.meloda.fast.ui.R as UiR
fun FriendsRoute( fun FriendsRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>() viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>()
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -99,11 +100,12 @@ fun FriendsRoute(
onPaginationConditionsMet = viewModel::onPaginationConditionsMet, onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setSelectedTabIndex = viewModel::onTabSelected, setSelectedTabIndex = viewModel::onTabSelected,
setScrollIndex = viewModel::setScrollIndex, setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset, setScrollOffset = viewModel::setScrollOffset,
setScrollIndexOnline = viewModel::setScrollIndexOnline, setScrollIndexOnline = viewModel::setScrollIndexOnline,
setScrollOffsetOnline = viewModel::setScrollOffsetOnline, setScrollOffsetOnline = viewModel::setScrollOffsetOnline
) )
} }
@@ -120,11 +122,12 @@ fun FriendsScreen(
onPaginationConditionsMet: () -> Unit = {}, onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {},
setSelectedTabIndex: (Int) -> Unit = {}, setSelectedTabIndex: (Int) -> Unit = {},
setScrollIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}, setScrollOffset: (Int) -> Unit = {},
setScrollIndexOnline: (Int) -> Unit = {}, setScrollIndexOnline: (Int) -> Unit = {},
setScrollOffsetOnline: (Int) -> Unit = {}, setScrollOffsetOnline: (Int) -> Unit = {}
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -231,7 +234,7 @@ fun FriendsScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.hazeChild( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.thick() style = HazeMaterials.thick()
) )
@@ -281,14 +284,26 @@ fun FriendsScreen(
} }
) { padding -> ) { padding ->
when { when {
baseError is BaseError.SessionExpired -> { baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView( ErrorView(
text = "Session expired", text = stringResource(UiR.string.session_expired),
buttonText = "Log out", buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked onButtonClick = onSessionExpiredLogOutButtonClicked
) )
} }
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
}
}
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
else -> { else -> {
@@ -333,15 +348,17 @@ fun FriendsScreen(
) )
} }
) { ) {
val friendsToDisplay = if (index == 0) { val friendsToDisplay = remember(index) {
if (index == 0) {
screenState.friends screenState.friends
} else { } else {
screenState.onlineFriends screenState.onlineFriends
} }
}
FriendsList( FriendsList(
modifier = if (currentTheme.enableBlur) { modifier = if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState) Modifier.hazeSource(state = hazeState)
} else { } else {
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
@@ -351,6 +368,7 @@ fun FriendsScreen(
maxLines = maxLines, maxLines = maxLines,
padding = padding, padding = padding,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { can -> setCanScrollBackward = { can ->
canScrollBackward = can canScrollBackward = can
} }
@@ -358,10 +376,9 @@ fun FriendsScreen(
if (friendsToDisplay.isEmpty()) { if (friendsToDisplay.isEmpty()) {
NoItemsView( NoItemsView(
modifier = Modifier customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null,
.padding(padding.calculateTopPadding()) buttonText = stringResource(UiR.string.action_refresh),
.padding(top = 16.dp), onButtonClick = onRefresh
customText = "No${if (index == 1) " online" else ""} friends :("
) )
} }
} }
@@ -24,11 +24,13 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.navigation.MessagesHistory import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.messageshistory.util.asPresentation import dev.meloda.fast.messageshistory.util.asPresentation
import dev.meloda.fast.messageshistory.util.extractAvatar import dev.meloda.fast.messageshistory.util.extractAvatar
import dev.meloda.fast.messageshistory.util.extractTitle import dev.meloda.fast.messageshistory.util.extractTitle
import dev.meloda.fast.messageshistory.util.findMessageById
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
@@ -160,7 +162,9 @@ class MessagesHistoryViewModelImpl(
val message = event.message val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message") Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.conversationId) return if (message.peerId != screenState.value.conversationId) return
if (screenState.value.messages.findMessageById(message.id) != null) return
val randomIds = messages.value.map(VkMessage::randomId) val randomIds = messages.value.map(VkMessage::randomId)
if (message.randomId != 0 && message.randomId in randomIds) return if (message.randomId != 0 && message.randomId in randomIds) return
@@ -174,22 +178,22 @@ class MessagesHistoryViewModelImpl(
val newMessage = message.asPresentation( val newMessage = message.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = prevMessage, prevMessage = prevMessage,
nextMessage = null, nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation,
) )
newMessages.add(0, newMessage) newMessages.add(0, newMessage)
prevMessage?.let { prev -> prevMessage?.let { prev ->
newMessages[1] = prev.asPresentation( newMessages[1] = prev.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = prevMessage, prevMessage = prevMessage,
nextMessage = messages.value.first(), nextMessage = messages.value.first(),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
) )
} }
@@ -205,11 +209,11 @@ class MessagesHistoryViewModelImpl(
?.let { index -> ?.let { index ->
val newMessage = message.asPresentation( val newMessage = message.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = messages.value.getOrNull(index + 1), prevMessage = messages.value.getOrNull(index + 1),
nextMessage = messages.value.getOrNull(index - 1), nextMessage = messages.value.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
) )
val newMessages = screenState.value.messages.toMutableList() val newMessages = screenState.value.messages.toMutableList()
@@ -224,7 +228,37 @@ class MessagesHistoryViewModelImpl(
} }
private fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) { 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) { private fun loadMessagesHistory(offset: Int = currentOffset.value) {
@@ -236,9 +270,7 @@ class MessagesHistoryViewModelImpl(
offset = offset, offset = offset,
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error -> },
},
success = { response -> success = { response ->
val messages = response.messages val messages = response.messages
val fullMessages = if (offset == 0) { val fullMessages = if (offset == 0) {
@@ -256,16 +288,6 @@ class MessagesHistoryViewModelImpl(
messagesUseCase.storeMessages(messages) messagesUseCase.storeMessages(messages)
conversationsUseCase.storeConversations(conversations) conversationsUseCase.storeConversations(conversations)
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
)
}
val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT
@@ -278,12 +300,25 @@ class MessagesHistoryViewModelImpl(
conversations conversations
.firstOrNull { it.id == screenState.value.conversationId } .firstOrNull { it.id == screenState.value.conversationId }
?.let { conversation -> ?.let { conversation ->
screenState.setValue { old -> old.copy(conversation = conversation) }
newState = newState.copy( newState = newState.copy(
title = conversation.extractTitle( title = conversation.extractTitle(
useContactName = AppSettings.General.useContactNames, useContactName = AppSettings.General.useContactNames,
resources = resourceProvider.resources resources = resourceProvider.resources
), ),
avatar = conversation.extractAvatar() avatar = conversation.extractAvatar(),
conversation = conversation
)
}
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
) )
} }
@@ -347,18 +382,14 @@ class MessagesHistoryViewModelImpl(
val newMessages = screenState.value.messages.toMutableList() val newMessages = screenState.value.messages.toMutableList()
val newUiMessage = newMessage.asPresentation( val newUiMessage = newMessage.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = messages.value.firstOrNull(), prevMessage = messages.value.firstOrNull(),
nextMessage = null, nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
) )
newMessages.add(0, newUiMessage) newMessages.add(0, newUiMessage)
messages.setValue { old ->
listOf(newMessage).plus(old)
}
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
message = TextFieldValue(), message = TextFieldValue(),
@@ -377,19 +408,37 @@ class MessagesHistoryViewModelImpl(
state.processState( state.processState(
error = { error -> error = { error ->
sendingMessages -= newMessage sendingMessages -= newMessage
val uiMessages = screenState.value.messages.toMutableList()
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 = uiMessages) }
}, },
success = { messageId -> success = { messageId ->
sendingMessages += newMessage sendingMessages -= newMessage
val messages = screenState.value.messages.toMutableList() val uiMessages = screenState.value.messages.toMutableList()
messages.setValue { old ->
listOf(newMessage.copy(id = messageId)).plus(old)
}
messages.indexOfOrNull(newUiMessage)?.let { index -> uiMessages.indexOfOrNull(newUiMessage)?.let { index ->
(messages[index] as? UiItem.Message)?.let { message -> (uiMessages[index] as? UiItem.Message)?.let { message ->
messages[index] = message.copy(id = messageId) uiMessages[index] = message
.copy(
id = messageId,
sendingStatus = SendingStatus.SENT
)
.copy(isRead = newMessage.isRead(screenState.value.conversation))
} }
} }
screenState.setValue { old -> old.copy(messages = messages) } screenState.setValue { old -> old.copy(messages = uiMessages) }
} }
) )
} }
@@ -508,11 +557,11 @@ class MessagesHistoryViewModelImpl(
val uiMessages = messages.mapIndexed { index, item -> val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation( item.asPresentation(
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
showDate = false,
showName = false, showName = false,
prevMessage = messages.getOrNull(index + 1), prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1), nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = show showTimeInActionMessages = show,
conversation = screenState.value.conversation
) )
} }
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
@Immutable @Immutable
data class MessagesHistoryScreenState( data class MessagesHistoryScreenState(
@@ -18,7 +19,8 @@ data class MessagesHistoryScreenState(
val isPaginating: Boolean, val isPaginating: Boolean,
val isPaginationExhausted: Boolean, val isPaginationExhausted: Boolean,
val actionMode: ActionMode, val actionMode: ActionMode,
val chatImageUrl: String? val chatImageUrl: String?,
val conversation: VkConversation
) { ) {
companion object { companion object {
@@ -34,7 +36,8 @@ data class MessagesHistoryScreenState(
isPaginating = false, isPaginating = false,
isPaginationExhausted = false, isPaginationExhausted = false,
actionMode = ActionMode.Record, actionMode = ActionMode.Record,
chatImageUrl = null chatImageUrl = null,
conversation = VkConversation.EMPTY
) )
} }
} }
@@ -0,0 +1,5 @@
package dev.meloda.fast.messageshistory.model
enum class SendingStatus {
SENDING, SENT, FAILED
}
@@ -22,7 +22,9 @@ sealed class UiItem(
val showAvatar: Boolean, val showAvatar: Boolean,
val showName: Boolean, val showName: Boolean,
val avatar: UiImage, val avatar: UiImage,
val isEdited: Boolean val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT
) : UiItem(id, conversationMessageId) ) : UiItem(id, conversationMessageId)
data class ActionMessage( data class ActionMessage(
@@ -32,4 +34,3 @@ sealed class UiItem(
val actionCmId: Int? val actionCmId: Int?
) : UiItem(id, conversationMessageId) ) : UiItem(id, conversationMessageId)
} }
@@ -31,6 +31,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun IncomingMessageBubble( fun IncomingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
animate: Boolean
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -80,6 +81,9 @@ fun IncomingMessageBubble(
isOut = false, isOut = false,
date = message.date, date = message.date,
edited = message.isEdited, edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
) )
} }
} }
@@ -1,19 +1,35 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun MessageBubble( fun MessageBubble(
@@ -22,6 +38,9 @@ fun MessageBubble(
isOut: Boolean, isOut: Boolean,
date: String?, date: String?,
edited: Boolean, edited: Boolean,
animate: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus
) { ) {
val backgroundColor = if (!isOut) { val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
@@ -45,44 +64,70 @@ fun MessageBubble(
vertical = 6.dp vertical = 6.dp
) )
) { ) {
val minDateContainerWidth = remember(edited, isOut) {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
mainPart + readIndicatorPart
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
label = "dateContainerWidth"
)
if (text != null) { if (text != null) {
Text( Text(
text = text, text = text,
modifier = Modifier modifier = Modifier
.padding(2.dp) .padding(2.dp)
.align(Alignment.Center) .align(Alignment.Center)
.animateContentSize(), .padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (animate) Modifier.animateContentSize() else Modifier),
color = textColor color = textColor
) )
} }
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth)
) {
if (edited) {
Icon(
imageVector = Icons.Rounded.Create,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall,
)
Spacer(modifier = Modifier.width(4.dp))
// val dateContainerWidth by animateDpAsState( if (isOut) {
// targetValue = if (edited) 50.dp else 30.dp, Icon(
// label = "dateContainerWidth" modifier = Modifier.size(14.dp),
// ) painter = painterResource(
when (sendingStatus) {
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
SendingStatus.SENT -> {
if (isRead) UiR.drawable.round_done_all_24
else UiR.drawable.ic_round_done_24
}
// AnimatedVisibility( SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
// date != null, }
// modifier = Modifier ),
// .width(dateContainerWidth) tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
// .align(Alignment.BottomEnd) else LocalContentColor.current,
// ) { contentDescription = null
// Row(modifier = Modifier.fillMaxWidth()) { )
// if (edited) { }
// Icon( }
// imageVector = Icons.Rounded.Create,
// contentDescription = null,
// modifier = Modifier.size(14.dp)
// )
// Spacer(modifier = Modifier.width(4.dp))
// }
// Text(
// text = date.orEmpty(),
// style = MaterialTheme.typography.labelSmall
// )
// Spacer(modifier = Modifier.width(2.dp))
// }
// }
} }
} }
@@ -159,7 +159,7 @@ fun MessagesHistoryScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val paginationConditionMet by remember { val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf { derivedStateOf {
canPaginate && canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
@@ -282,6 +282,7 @@ fun MessagesHistoryScreen(
// TODO: 11/07/2024, Danil Nikolaev: to VM // TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat)
onChatMaterialsDropdownItemClicked( onChatMaterialsDropdownItemClicked(
screenState.conversationId, screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId screenState.messages.firstMessage().conversationMessageId
@@ -98,6 +98,7 @@ fun MessagesList(
else Modifier else Modifier
), ),
message = item, message = item,
animate = enableAnimations
) )
} else { } else {
IncomingMessageBubble( IncomingMessageBubble(
@@ -110,6 +111,7 @@ fun MessagesList(
else Modifier else Modifier
), ),
message = item, message = item,
animate = enableAnimations
) )
} }
} }
@@ -128,16 +130,17 @@ fun MessagesList(
} }
} }
Spacer( Spacer(Modifier.height(8.dp))
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.height(64.dp) .height(64.dp)
.fillMaxWidth() .fillMaxWidth()
) )
Spacer(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
} }
} }
} }
@@ -3,12 +3,8 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -20,6 +16,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun OutgoingMessageBubble( fun OutgoingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
animate: Boolean
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -37,18 +34,12 @@ fun OutgoingMessageBubble(
modifier = Modifier, modifier = Modifier,
text = message.text.orDots(), text = message.text.orDots(),
isOut = true, isOut = true,
date = null, date = message.date,
edited = message.isEdited, edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
) )
if (message.showDate) {
Spacer(modifier = Modifier.height(2.dp))
Text(
modifier = Modifier.padding(end = 12.dp),
text = message.date,
style = MaterialTheme.typography.labelSmall
)
}
} }
} }
} }
@@ -4,11 +4,13 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first() fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
fun List<UiItem>.indexOfMessageById(messageId: Int): Int = fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
indexOfFirst { it.id == messageId } indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message = fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
first { it.id == messageId } as UiItem.Message firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int = fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
indexOfFirst { it.cmId == cmId } indexOfFirst { it.cmId == cmId }
@@ -12,6 +12,7 @@ import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
@@ -90,8 +91,8 @@ fun VkConversation.extractTitle(
}.parseString(resources).orDots() }.parseString(resources).orDots()
fun VkMessage.asPresentation( fun VkMessage.asPresentation(
conversation: VkConversation,
resourceProvider: ResourceProvider, resourceProvider: ResourceProvider,
showDate: Boolean,
showName: Boolean, showName: Boolean,
prevMessage: VkMessage?, prevMessage: VkMessage?,
nextMessage: VkMessage?, nextMessage: VkMessage?,
@@ -118,15 +119,19 @@ fun VkMessage.asPresentation(
randomId = randomId, randomId = randomId,
isInChat = isPeerChat(), isInChat = isPeerChat(),
name = extractTitle(), name = extractTitle(),
showDate = showDate, showDate = true,
showAvatar = extractShowAvatar(nextMessage), showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage), showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(), avatar = extractAvatar(),
isEdited = updateTime != null isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
}
) )
} }
fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean { fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean {
if (isOut) return false if (isOut) return false
return nextMessage == null || nextMessage.fromId != fromId return nextMessage == null || nextMessage.fromId != fromId
+5 -1
View File
@@ -19,13 +19,16 @@ dependencyResolutionManagement {
rootProject.name = "fast-messenger" rootProject.name = "fast-messenger"
include(":app") include(":app")
include(":core:network") include(":core:network")
include(":core:data") include(":core:data")
include(":core:database") include(":core:database")
include(":core:datastore") include(":core:datastore")
include(":core:ui") include(":core:ui")
include(":core:common") include(":core:common")
include(":core:domain")
include(":core:model") include(":core:model")
include(":feature:messageshistory") include(":feature:messageshistory")
include(":feature:conversations") include(":feature:conversations")
include(":feature:auth") include(":feature:auth")
@@ -35,4 +38,5 @@ include(":feature:photoviewer")
include(":feature:settings") include(":feature:settings")
include(":feature:friends") include(":feature:friends")
include(":feature:profile") include(":feature:profile")
include(":core:domain") include(":feature:createchat")
include(":core:presentation")