7 Commits

Author SHA1 Message Date
melod1n a4feb8978f message sending status 2025-03-23 09:08:29 +03:00
melod1n 79f539a27b - read indicator, edit status and time for message in messages history 2025-03-23 08:45:01 +03:00
melod1n 4cc6ec6b5d Chat creation feature (#138) 2025-03-23 07:33:58 +03:00
melod1n 36a119ffa9 Refactor: Enhance conversations and friends features
-   In `ConversationsScreen`, removed `isNeedToScrollToTop` and `onScrolledToTop`, and refactored toolbar container color logic. Added `NoItemsView` for empty conversation lists.
-   In `MainGraph`, added `onMessageClicked` for navigation to message history.
-   In `ApiEvent`, introduced `parseOrNull` for handling unknown event types.
-   In `ConversationsViewModel`, removed `scrollToTop` logic and refactored error handling.
-   In `FriendsViewModel`, refactored error handling and introduced `onErrorConsumed` and `handleError`.
-   In `FriendItem`, added an icon button to initiate sending a message to a friend.
-   In `strings.xml`, added or updated strings for session expiration, log out, refreshing, and empty friend lists.
-   In `RootScreen`, added `onMessageClicked` for navigating to messages.
-   In `FriendsList`, added `onMessageClicked` for handling message clicks.
-   In `MainScreen`, removed unused `MutableSharedFlow`.
-   In `FriendsScreen`, added support for showing errors, added `onMessageClicked`, and replaced `hazeChild` with `hazeEffect` and `hazeSource`.
-   In `FriendsNavigation`, added `onMessageClicked` for handling message clicks.
-   In `ConversationsNavigation`, removed the unused `scrollToTopFlow` parameter.
-   In `ErrorView`, added text alignment.
-   In `NoItemsView`, added support for a button and custom text.
-   In `LongPollUpdatesParser`, replaced try-catch with `parseOrNull`.
2025-03-21 12:43:22 +03:00
melod1n 1a78a51017 * fixed visual bug in progress bar in chat history 2025-03-21 04:49:17 +03:00
melod1n cbe3313b87 * pagination in chat fixed
* other fixes and improvements
2025-03-21 04:44:09 +03:00
melod1n 30e132d418 Release 0.1.7 (#136)
* Bump haze from 1.1.1 to 1.2.0 (#105)

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

* update gradle wrapper

* Bump agp from 8.7.3 to 8.8.0 (#106)

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

* Bump haze from 1.2.0 to 1.2.2 (#111)

* Bump koin from 4.0.1 to 4.0.2 (#112)

* little improvement

* Bump kotlin from 2.1.0 to 2.1.10 (#113)

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

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

* Bump haze from 1.2.2 to 1.3.1 (#118)

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

* Bump agp from 8.8.0 to 8.8.1 (#117)

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

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

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

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

* Bump haze from 1.3.1 to 1.4.0 (#124)

* Bump agp from 8.8.1 to 8.8.2 (#123)

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

* Bump haze from 1.4.0 to 1.5.0 (#128)

* Bump agp from 8.8.2 to 8.9.0 (#127)

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

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

* revert agp version to 8.8.2

* fix issues with package names

* Bump haze from 1.5.0 to 1.5.1 (#133)

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

* russian translations

* fixes and improvements

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-21 03:13:17 +03:00
81 changed files with 1687 additions and 458 deletions
+1
View File
@@ -77,6 +77,7 @@ dependencies {
implementation(projects.feature.friends)
implementation(projects.feature.profile)
implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat)
implementation(projects.core.common)
implementation(projects.core.ui)
@@ -16,6 +16,7 @@ import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.conversations.di.createChatModule
import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule
@@ -46,7 +47,8 @@ val applicationModule = module {
longPollModule,
friendsModule,
profileModule,
chatMaterialsModule
chatMaterialsModule,
createChatModule
)
// TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors
@@ -24,6 +24,8 @@ fun NavGraphBuilder.mainScreen(
onSettingsButtonClicked: () -> Unit,
onConversationClicked: (conversationId: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
onCreateChatClicked: () -> Unit,
viewModel: MainViewModel
) {
val navigationItems = ImmutableList.of(
@@ -54,6 +56,8 @@ fun NavGraphBuilder.mainScreen(
onSettingsButtonClicked = onSettingsButtonClicked,
onConversationItemClicked = onConversationClicked,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
onCreateChatClicked = onCreateChatClicked,
viewModel = viewModel
)
}
@@ -33,7 +33,7 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import coil.compose.SubcomposeAsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.MainViewModel
@@ -47,8 +47,6 @@ import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
@@ -58,6 +56,8 @@ fun MainScreen(
onSettingsButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {},
onCreateChatClicked: () -> Unit = {},
viewModel: MainViewModel
) {
val currentTheme = LocalThemeConfig.current
@@ -70,21 +70,13 @@ fun MainScreen(
mutableIntStateOf(1)
}
val sharedFlow = remember {
MutableSharedFlow<Int>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
Scaffold(
bottomBar = {
NavigationBar(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeChild(
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
@@ -108,8 +100,6 @@ fun MainScreen(
inclusive = true
}
}
} else {
sharedFlow.tryEmit(index)
}
},
icon = {
@@ -176,13 +166,14 @@ fun MainScreen(
friendsScreen(
onError = onError,
navController = navController,
onPhotoClicked = onPhotoClicked
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
)
conversationsScreen(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked,
scrollToTopFlow = sharedFlow,
onCreateChatClicked = onCreateChatClicked,
navController = navController,
)
profileScreen(
@@ -25,6 +25,8 @@ import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
@@ -124,6 +126,8 @@ fun RootScreen(
onSettingsButtonClicked = navController::navigateToSettings,
onConversationClicked = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
onMessageClicked = navController::navigateToMessagesHistory,
onCreateChatClicked = navController::navigateToCreateChat,
viewModel = viewModel
)
@@ -136,6 +140,13 @@ fun RootScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
)
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen(
onBack = navController::navigateUp,
@@ -1,10 +1,10 @@
package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface MessagesRepository {
@@ -41,6 +41,11 @@ interface MessagesRepository {
conversationMessageId: Int
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain>
suspend fun createChat(
userIds: List<Int>?,
title: String?
): ApiResult<Int, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>)
// suspend fun markAsImportant(
@@ -1,5 +1,6 @@
package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
@@ -14,6 +15,7 @@ import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
@@ -23,7 +25,6 @@ import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -198,6 +199,23 @@ class MessagesRepositoryImpl(
)
}
override suspend fun createChat(
userIds: List<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>) {
messageDao.insertAll(messages.map(VkMessage::asEntity))
}
@@ -20,4 +20,8 @@ class GetLocalUserByIdUseCase(private val repository: UsersRepository) {
emit(newState)
}
suspend fun proceed(userId: Int): VkUser? {
return repository.getLocalUsers(userIds = listOf(userId)).singleOrNull()
}
}
@@ -41,15 +41,9 @@ class LongPollUpdatesParser(
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
val eventType: ApiEvent = try {
ApiEvent.parse(eventId)
} catch (e: Exception) {
e.printStackTrace()
Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
return
}
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
when (eventType) {
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
@@ -42,6 +42,11 @@ interface MessagesUseCase {
conversationMessageId: Int
): Flow<State<List<VkAttachmentHistoryMessage>>>
fun createChat(
userIds: List<Int>?,
title: String?
): Flow<State<Int>>
suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>)
}
@@ -100,6 +100,14 @@ class MessagesUseCaseImpl(
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) {
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.data.VkMemoryCache
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.api.UiFriend
fun VkUser.asPresentation(
useContactNames: Boolean = false
@@ -16,5 +16,7 @@ fun VkUser.asPresentation(
fullName
},
onlineStatus = onlineStatus,
photo400Orig = photo400Orig?.let(UiImage::Url)
photo400Orig = photo400Orig?.let(UiImage::Url),
firstName = firstName,
lastName = lastName
)
@@ -18,5 +18,6 @@ enum class ApiEvent(val value: Int) {
companion object {
fun parse(value: Int) = entries.first { it.value == value }
fun parseOrNull(value: Int) = entries.firstOrNull { it.value == value }
}
}
@@ -38,6 +38,41 @@ data class VkConversation(
fun isPinned(): Boolean = majorId > 0
fun isInUnread() = inRead - (lastMessageId ?: 0) < 0
fun isOutUnread() = outRead - (lastMessageId ?: 0) < 0
companion object {
val EMPTY: VkConversation = VkConversation(
id = -1,
localId = -1,
ownerId = null,
title = "...",
photo50 = null,
photo100 = null,
photo200 = null,
isCallInProgress = false,
isPhantom = false,
lastConversationMessageId = -1,
inReadCmId = -1,
outReadCmId = -1,
inRead = -1,
outRead = -1,
lastMessageId = null,
unreadCount = -1,
membersCount = null,
canChangePin = false,
canChangeInfo = false,
majorId = -1,
minorId = -1,
pinnedMessageId = null,
interactionType = -1,
interactionIds = emptyList(),
peerType = PeerType.USER,
lastMessage = null,
pinnedMessage = null,
user = null,
group = null
)
}
}
fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity(
@@ -38,12 +38,11 @@ data class VkMessage(
fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation) =
if (isOut) {
conversation.outRead - id >= 0
} else {
conversation.inRead - id >= 0
}
fun isRead(conversation: VkConversation): Boolean = when {
id <= 0 -> false
isOut -> conversation.outRead - id >= 0
else -> conversation.inRead - id >= 0
}
fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty()
@@ -267,3 +267,14 @@ data class MessagesGetHistoryAttachmentsRequest(
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
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData
import dev.meloda.fast.model.api.data.VkChatMemberData
import dev.meloda.fast.model.api.data.VkContactData
@@ -7,8 +9,6 @@ import dev.meloda.fast.model.api.data.VkConversationData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class MessagesGetHistoryResponse(
@@ -44,3 +44,9 @@ data class MessagesGetHistoryAttachmentsResponse(
@Json(name = "groups") val groups: List<VkGroupData>?,
@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)
}.fold(
onSuccess = { successModel ->
if (successModel is ApiResponse<*>) {
if (successModel.error != null) {
throw ApiException(successModel.error)
}
}
return successModel
},
onFailure = { failure ->
@@ -42,6 +42,8 @@ enum class VkErrorCode(val code: Int) {
ACCESS_TO_DOC_DENIED(1153),
SOME_AUTH_ERROR(104),
CANNOT_SEND_MESSAGE_DUE_TO_PRIVACY_SETTINGS(902),
ACCESS_TOKEN_EXPIRED(1117);
companion object {
@@ -1,12 +1,13 @@
package dev.meloda.fast.network.service.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError
import com.slack.eithernet.ApiResult
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
@@ -49,6 +50,12 @@ interface MessagesService {
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.CREATE_CHAT)
suspend fun createChat(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesCreateChatResponse>, RestApiError>
// @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant)
// suspend fun markAsImportant(
@@ -19,4 +19,5 @@ object MessagesUrls {
const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers"
const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments"
const val CREATE_CHAT = "${AppConstants.URL_API}/messages.createChat"
}
+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 {
api(projects.core.common)
api(projects.core.model)
implementation(projects.core.presentation)
implementation(libs.haze)
implementation(libs.haze.materials)
@@ -12,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -31,7 +32,8 @@ fun ErrorView(
) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
buttonText?.let {
@@ -29,9 +29,9 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IconButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
interactionSource: MutableInteractionSource? = null,
@@ -1,29 +1,51 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R
@Composable
fun NoItemsView(
modifier: Modifier = Modifier,
customText: String? = null
customText: String? = null,
buttonText: String? = null,
onButtonClick: (() -> Unit)? = null,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = customText ?: stringResource(id = R.string.no_items),
style = MaterialTheme.typography.titleLarge
text = customText ?: stringResource(R.string.no_items),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
buttonText?.let {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { onButtonClick?.invoke() }
) {
Text(text = buttonText)
}
}
}
}
@@ -31,6 +53,7 @@ fun NoItemsView(
@Composable
private fun NoItemsViewPreview() {
NoItemsView(
customText = "Nothing here..."
customText = "Nothing here...",
buttonText = "Refresh"
)
}
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model
package dev.meloda.fast.ui.model.api
enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE;
@@ -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(
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.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.model.api.domain.OnlineStatus
@Immutable
data class UiFriend(
val userId: Int,
val avatar: UiImage?,
val firstName: String,
val lastName: String,
val title: String,
val onlineStatus: OnlineStatus,
val photo400Orig: UiImage?
@@ -1,7 +1,6 @@
package dev.meloda.fast.ui.util
import android.content.res.Configuration
import android.graphics.drawable.ColorDrawable
import android.os.PowerManager
import android.view.KeyEvent
import androidx.compose.foundation.lazy.LazyListState
@@ -21,6 +20,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toDrawable
import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
@@ -64,8 +64,8 @@ fun UiImage.getResourcePainter(): Painter? {
@Composable
fun UiImage.getImage(): Any {
return when (this) {
is UiImage.Color -> ColorDrawable(color)
is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb())
is UiImage.Color -> color.toDrawable()
is UiImage.ColorResource -> colorResource(id = resId).toArgb().toDrawable()
is UiImage.Resource -> painterResource(id = resId)
is UiImage.Simple -> drawable
is UiImage.Url -> url
@@ -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">
<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 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" />
</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">
<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 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" />
</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">
<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="@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="@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="@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"/>
<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="#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="#ffffff"
android:fillType="evenOdd"
android:pathData="M9,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13z" />
</vector>
@@ -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">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z"/>
<path android:fillColor="@android:color/white" android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z"/>
<vector 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,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>
@@ -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">
<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 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" />
</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">
<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="M9,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8C5,10.21 6.79,12 9,12zM9,6c1.1,0 2,0.9 2,2c0,1.1 -0.9,2 -2,2S7,9.1 7,8C7,6.9 7.9,6 9,6z"/>
<path android:fillColor="@android:color/white" android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13zM15,18H3l0,-0.99C3.2,16.29 6.3,15 9,15s5.8,1.29 6,2V18z"/>
<vector 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="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8C5,10.21 6.79,12 9,12zM9,6c1.1,0 2,0.9 2,2c0,1.1 -0.9,2 -2,2S7,9.1 7,8C7,6.9 7.9,6 9,6z" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13zM15,18H3l0,-0.99C3.2,16.29 6.3,15 9,15s5.8,1.29 6,2V18z" />
</vector>
@@ -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_user">Запись пользователя</string>
<string name="post_type_unknown">Запись на стене</string>
<string name="log_out">Выйти</string>
<string name="action_log_out">Выйти</string>
<string name="confirm">Подтверждение</string>
<string name="message_attachment_story_your_story">Ваша история</string>
<string name="settings_dynamic_colors">Динамические цвета</string>
@@ -212,4 +212,10 @@
<string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string>
<string name="validation_exit_warning">Вы уверены? Процесс ввода кода-подтверждения будет отменён</string>
<string name="action_authorize">Авторизоваться</string>
<string name="no_online_friends">Никого в сети</string>
<string name="try_again">Попробовать ещё раз</string>
<string name="session_expired">Срок действия сессии истёк</string>
<string name="title_create_chat">Создать чат</string>
<string name="action_create">Создать</string>
<string name="create_chat_title">Название</string>
</resources>
+7 -2
View File
@@ -119,7 +119,7 @@
<string name="post_type_community">Community post</string>
<string name="post_type_user">User 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="sign_out_confirm">Signing out will delete all data related to this account from this device. Continue?</string>
<string name="yes">Yes</string>
@@ -276,6 +276,11 @@
<string name="warning_confirmation">Confirmation</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="settings_general_enable_pull_to_refresh_title">Enable pull to refresh</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>
@@ -11,10 +11,7 @@ import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.ConversationsShowOptions
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.conversations.util.asPresentation
import dev.meloda.fast.conversations.util.extractAvatar
import dev.meloda.fast.data.State
@@ -29,10 +26,11 @@ import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -40,7 +38,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
interface ConversationsViewModel {
@@ -49,7 +46,6 @@ interface ConversationsViewModel {
val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
val scrollToTop: StateFlow<Boolean>
fun onPaginationConditionsMet()
@@ -70,10 +66,6 @@ interface ConversationsViewModel {
fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int)
fun setScrollToTopFlow(scrollToTopFlow: Flow<Int>)
fun onScrolledToTop()
}
class ConversationsViewModelImpl(
@@ -91,12 +83,8 @@ class ConversationsViewModelImpl(
override val baseError = MutableStateFlow<BaseError?>(null)
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override val scrollToTop = MutableStateFlow(false)
// TODO: 22-Dec-24, Danil Nikolaev: rewrite
private val useContactNames = {
userSettings.useContactNames.value
}
private val useContactNames: Boolean get() = userSettings.useContactNames.value
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size }
@@ -134,7 +122,7 @@ class ConversationsViewModelImpl(
}
override fun onRefresh() {
baseError.setValue { null }
onErrorConsumed()
loadConversations(offset = 0)
}
@@ -177,11 +165,11 @@ class ConversationsViewModelImpl(
conversations = old.conversations.map { item ->
item.copy(
isExpanded =
if (item.id == conversation.id) {
!item.isExpanded
} else {
false
},
if (item.id == conversation.id) {
!item.isExpanded
} else {
false
},
options = ImmutableList.copyOf(options)
)
}
@@ -200,7 +188,10 @@ class ConversationsViewModelImpl(
onPinDialogDismissed()
}
override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) {
override fun onOptionClicked(
conversation: UiConversation,
option: ConversationOption
) {
when (option) {
ConversationOption.Delete -> {
emitShowOptions { old ->
@@ -237,20 +228,6 @@ class ConversationsViewModelImpl(
screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
override fun setScrollToTopFlow(scrollToTopFlow: Flow<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) {
screenState.setValue { old ->
old.copy(
@@ -345,21 +322,25 @@ class ConversationsViewModelImpl(
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
@@ -383,7 +364,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -447,7 +428,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -498,7 +479,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -527,7 +508,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -557,7 +538,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -586,7 +567,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -632,7 +613,7 @@ class ConversationsViewModelImpl(
old.copy(conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
})
}
@@ -673,7 +654,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -724,7 +705,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -758,7 +739,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -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
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
@Immutable
data class ConversationsScreenState(
@@ -8,7 +8,6 @@ import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
@Serializable
@@ -18,18 +17,18 @@ fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
scrollToTopFlow: Flow<Int>,
onCreateChatClicked: () -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
viewModel.setScrollToTopFlow(scrollToTopFlow)
ConversationsRoute(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel
)
}
@@ -48,11 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString
@@ -256,7 +256,7 @@ fun ConversationItem(
Row {
if (conversation.interactionText != null) {
Text(
text = conversation.interactionText,
text = conversation.interactionText.orEmpty(),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
@@ -23,10 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -83,8 +82,7 @@ fun ConversationsList(
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.navigationBarsPadding(),
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
@@ -107,11 +105,9 @@ fun ConversationsList(
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(bottomPadding))
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -2,7 +2,6 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -48,12 +47,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -62,28 +59,26 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@Composable
@@ -91,12 +86,12 @@ fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle()
ConversationsScreen(
screenState = screenState,
@@ -113,10 +108,9 @@ fun ConversationsRoute(
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
isNeedToScrollToTop = isNeedToScrollToTop,
onScrolledToTop = viewModel::onScrolledToTop
setScrollOffset = viewModel::setScrollOffset
)
HandleDialogs(
@@ -134,7 +128,7 @@ fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
@@ -142,10 +136,9 @@ fun ConversationsScreen(
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
isNeedToScrollToTop: Boolean = false,
onScrolledToTop: () -> Unit = {}
setScrollOffset: (Int) -> Unit = {}
) {
val view = LocalView.current
val currentTheme = LocalThemeConfig.current
@@ -159,14 +152,6 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
LaunchedEffect(isNeedToScrollToTop) {
if (isNeedToScrollToTop) {
listState.scrollToItem(0)
onScrolledToTop()
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L)
@@ -207,10 +192,10 @@ fun ConversationsScreen(
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
if (currentTheme.enableBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
@@ -275,7 +260,7 @@ fun ConversationsScreen(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeChild(
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
@@ -296,37 +281,13 @@ fun ConversationsScreen(
}
},
floatingActionButton = {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Column {
AnimatedVisibility(
visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) {
FloatingActionButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
modifier = Modifier.rotate(rotation.value)
) {
FloatingActionButton(onClick = onCreateChatButtonClicked) {
Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button"
@@ -343,8 +304,8 @@ fun ConversationsScreen(
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = "Session expired",
buttonText = "Log out",
text = stringResource(UiR.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
@@ -352,7 +313,7 @@ fun ConversationsScreen(
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = "Try again",
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
@@ -390,7 +351,7 @@ fun ConversationsScreen(
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState)
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
@@ -398,6 +359,13 @@ fun ConversationsScreen(
padding = padding,
onPhotoClicked = onConversationPhotoClicked
)
if (screenState.conversations.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
@@ -422,9 +390,7 @@ fun HandleDialogs(
)
}
if (showOptions.showPinDialog != null) {
val conversation = showOptions.showPinDialog
showOptions.showPinDialog?.let { conversation ->
MaterialDialog(
onDismissRequest = viewModel::onPinDialogDismissed,
title = stringResource(
@@ -14,8 +14,6 @@ import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.conversations.model.ActionState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.InteractionType
@@ -24,6 +22,8 @@ import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList
import java.util.Calendar
import java.util.Locale
+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.domain.FriendsUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.util.asPresentation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode
@@ -68,6 +68,7 @@ class FriendsViewModelImpl(
}
override fun onRefresh() {
onErrorConsumed()
loadFriends(offset = 0)
}
@@ -99,32 +100,12 @@ class FriendsViewModelImpl(
friendsUseCase.getOnlineFriends(null, null)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
error = ::handleError,
success = { userIds ->
loadUsersByIdsUseCase(userIds = userIds)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
error = ::handleError,
success = { onlineFriends ->
screenState.setValue { old ->
old.copy(
@@ -142,17 +123,7 @@ class FriendsViewModelImpl(
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
@@ -197,6 +168,40 @@ class FriendsViewModelImpl(
}
}
private fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
private fun updateFriendsNames(useContactNames: Boolean) {
val friends = friends.value
if (friends.isEmpty()) return
@@ -1,6 +1,7 @@
package dev.meloda.fast.friends.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable
data class FriendsScreenState(
@@ -16,7 +16,8 @@ object Friends
fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit,
navController: NavController,
onPhotoClicked: (url: String) -> Unit
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
) {
composable<Friends> {
val viewModel: FriendsViewModel =
@@ -25,7 +26,8 @@ fun NavGraphBuilder.friendsScreen(
FriendsRoute(
onError = onError,
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.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MailOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -23,15 +27,16 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable
fun FriendItem(
modifier: Modifier = Modifier,
friend: UiFriend,
maxLines: Int,
onPhotoClicked: (url: String) -> Unit
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
) {
Row(
modifier = modifier.fillMaxWidth(),
@@ -92,9 +97,24 @@ fun FriendItem(
text = friend.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp)
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = {
onMessageClicked(friend.userId)
}
) {
Icon(
imageVector = Icons.Rounded.MailOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(16.dp))
}
}
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
@@ -23,8 +22,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -38,6 +36,7 @@ fun FriendsList(
maxLines: Int,
padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
setCanScrollBackward: (Boolean) -> Unit
) {
LaunchedEffect(listState) {
@@ -49,8 +48,6 @@ fun FriendsList(
val friends = uiFriends.toList()
val bottomPadding = LocalBottomPadding.current
LazyColumn(
modifier = modifier,
state = listState
@@ -67,7 +64,8 @@ fun FriendsList(
FriendItem(
friend = friend,
maxLines = maxLines,
onPhotoClicked = onPhotoClicked
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
)
Spacer(modifier = Modifier.height(16.dp))
@@ -77,8 +75,7 @@ fun FriendsList(
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.navigationBarsPadding(),
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
@@ -101,11 +98,9 @@ fun FriendsList(
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(bottomPadding))
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -48,8 +48,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.friends.FriendsViewModel
@@ -72,6 +72,7 @@ import dev.meloda.fast.ui.R as UiR
fun FriendsRoute(
onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>()
) {
val context = LocalContext.current
@@ -99,11 +100,12 @@ fun FriendsRoute(
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setSelectedTabIndex = viewModel::onTabSelected,
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
setScrollIndexOnline = viewModel::setScrollIndexOnline,
setScrollOffsetOnline = viewModel::setScrollOffsetOnline,
setScrollOffsetOnline = viewModel::setScrollOffsetOnline
)
}
@@ -120,11 +122,12 @@ fun FriendsScreen(
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {},
setSelectedTabIndex: (Int) -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
setScrollIndexOnline: (Int) -> Unit = {},
setScrollOffsetOnline: (Int) -> Unit = {},
setScrollOffsetOnline: (Int) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
@@ -231,7 +234,7 @@ fun FriendsScreen(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeChild(
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
@@ -281,12 +284,24 @@ fun FriendsScreen(
}
) { padding ->
when {
baseError is BaseError.SessionExpired -> {
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = onSessionExpiredLogOutButtonClicked
)
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(UiR.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
}
}
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
@@ -333,15 +348,17 @@ fun FriendsScreen(
)
}
) {
val friendsToDisplay = if (index == 0) {
screenState.friends
} else {
screenState.onlineFriends
val friendsToDisplay = remember(index) {
if (index == 0) {
screenState.friends
} else {
screenState.onlineFriends
}
}
FriendsList(
modifier = if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState)
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
@@ -351,6 +368,7 @@ fun FriendsScreen(
maxLines = maxLines,
padding = padding,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { can ->
canScrollBackward = can
}
@@ -358,10 +376,9 @@ fun FriendsScreen(
if (friendsToDisplay.isEmpty()) {
NoItemsView(
modifier = Modifier
.padding(padding.calculateTopPadding())
.padding(top = 16.dp),
customText = "No${if (index == 1) " online" else ""} friends :("
customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null,
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
}
@@ -24,11 +24,13 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.messageshistory.util.asPresentation
import dev.meloda.fast.messageshistory.util.extractAvatar
import dev.meloda.fast.messageshistory.util.extractTitle
import dev.meloda.fast.messageshistory.util.findMessageById
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkAttachment
@@ -160,7 +162,9 @@ class MessagesHistoryViewModelImpl(
val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.conversationId) return
if (screenState.value.messages.findMessageById(message.id) != null) return
val randomIds = messages.value.map(VkMessage::randomId)
if (message.randomId != 0 && message.randomId in randomIds) return
@@ -174,22 +178,22 @@ class MessagesHistoryViewModelImpl(
val newMessage = message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = prevMessage,
nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation,
)
newMessages.add(0, newMessage)
prevMessage?.let { prev ->
newMessages[1] = prev.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = prevMessage,
nextMessage = messages.value.first(),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
}
@@ -205,11 +209,11 @@ class MessagesHistoryViewModelImpl(
?.let { index ->
val newMessage = message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.value.getOrNull(index + 1),
nextMessage = messages.value.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
val newMessages = screenState.value.messages.toMutableList()
@@ -224,7 +228,37 @@ class MessagesHistoryViewModelImpl(
}
private fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) {
if (event.peerId != screenState.value.conversationId) return
val messages = messages.value
val messageIndex =
messages.indexOfFirstOrNull { it.id == event.messageId }
if (messageIndex == null) { // диалога нет в списке
// pizdets
} else {
val newConversation = screenState.value.conversation.copy(
outRead = event.messageId
)
val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = newConversation
)
}
screenState.setValue { old ->
old.copy(
conversation = newConversation,
messages = uiMessages,
)
}
}
}
private fun loadMessagesHistory(offset: Int = currentOffset.value) {
@@ -236,9 +270,7 @@ class MessagesHistoryViewModelImpl(
offset = offset,
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
},
error = { error -> },
success = { response ->
val messages = response.messages
val fullMessages = if (offset == 0) {
@@ -256,16 +288,6 @@ class MessagesHistoryViewModelImpl(
messagesUseCase.storeMessages(messages)
conversationsUseCase.storeConversations(conversations)
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
)
}
val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT
@@ -278,15 +300,28 @@ class MessagesHistoryViewModelImpl(
conversations
.firstOrNull { it.id == screenState.value.conversationId }
?.let { conversation ->
screenState.setValue { old -> old.copy(conversation = conversation) }
newState = newState.copy(
title = conversation.extractTitle(
useContactName = AppSettings.General.useContactNames,
resources = resourceProvider.resources
),
avatar = conversation.extractAvatar()
avatar = conversation.extractAvatar(),
conversation = conversation
)
}
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
}
this.messages.emit(fullMessages)
screenState.setValue { newState.copy(messages = loadedMessages) }
canPaginate.setValue { itemsCountSufficient }
@@ -347,18 +382,14 @@ class MessagesHistoryViewModelImpl(
val newMessages = screenState.value.messages.toMutableList()
val newUiMessage = newMessage.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.value.firstOrNull(),
nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
newMessages.add(0, newUiMessage)
messages.setValue { old ->
listOf(newMessage).plus(old)
}
screenState.setValue { old ->
old.copy(
message = TextFieldValue(),
@@ -377,19 +408,37 @@ class MessagesHistoryViewModelImpl(
state.processState(
error = { error ->
sendingMessages -= newMessage
},
success = { messageId ->
sendingMessages += newMessage
val messages = screenState.value.messages.toMutableList()
val uiMessages = screenState.value.messages.toMutableList()
messages.indexOfOrNull(newUiMessage)?.let { index ->
(messages[index] as? UiItem.Message)?.let { message ->
messages[index] = message.copy(id = messageId)
uiMessages.indexOfOrNull(newUiMessage)?.let { index ->
(uiMessages[index] as? UiItem.Message)?.let { message ->
uiMessages[index] = message.copy(sendingStatus = SendingStatus.FAILED)
}
}
screenState.setValue { old -> old.copy(messages = messages) }
screenState.setValue { old -> old.copy(messages = uiMessages) }
},
success = { messageId ->
sendingMessages -= newMessage
val uiMessages = screenState.value.messages.toMutableList()
messages.setValue { old ->
listOf(newMessage.copy(id = messageId)).plus(old)
}
uiMessages.indexOfOrNull(newUiMessage)?.let { index ->
(uiMessages[index] as? UiItem.Message)?.let { message ->
uiMessages[index] = message
.copy(
id = messageId,
sendingStatus = SendingStatus.SENT
)
.copy(isRead = newMessage.isRead(screenState.value.conversation))
}
}
screenState.setValue { old -> old.copy(messages = uiMessages) }
}
)
}
@@ -508,11 +557,11 @@ class MessagesHistoryViewModelImpl(
val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = show
showTimeInActionMessages = show,
conversation = screenState.value.conversation
)
}
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
@Immutable
data class MessagesHistoryScreenState(
@@ -18,7 +19,8 @@ data class MessagesHistoryScreenState(
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val actionMode: ActionMode,
val chatImageUrl: String?
val chatImageUrl: String?,
val conversation: VkConversation
) {
companion object {
@@ -34,7 +36,8 @@ data class MessagesHistoryScreenState(
isPaginating = false,
isPaginationExhausted = false,
actionMode = ActionMode.Record,
chatImageUrl = null
chatImageUrl = null,
conversation = VkConversation.EMPTY
)
}
}
@@ -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 showName: Boolean,
val avatar: UiImage,
val isEdited: Boolean
val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT
) : UiItem(id, conversationMessageId)
data class ActionMessage(
@@ -32,4 +34,3 @@ sealed class UiItem(
val actionCmId: Int?
) : UiItem(id, conversationMessageId)
}
@@ -31,6 +31,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun IncomingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
val context = LocalContext.current
@@ -44,12 +45,12 @@ fun IncomingMessageBubble(
if (message.isInChat) {
Image(
painter =
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = context.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = context.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null,
modifier = Modifier
.padding(bottom = 6.dp)
@@ -80,6 +81,9 @@ fun IncomingMessageBubble(
isOut = false,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
)
}
}
@@ -1,19 +1,35 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.ui.R as UiR
@Composable
fun MessageBubble(
@@ -22,6 +38,9 @@ fun MessageBubble(
isOut: Boolean,
date: String?,
edited: Boolean,
animate: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus
) {
val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
@@ -45,44 +64,70 @@ fun MessageBubble(
vertical = 6.dp
)
) {
val minDateContainerWidth = remember(edited, isOut) {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
mainPart + readIndicatorPart
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
label = "dateContainerWidth"
)
if (text != null) {
Text(
text = text,
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
.animateContentSize(),
.padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (animate) Modifier.animateContentSize() else Modifier),
color = textColor
)
}
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth)
) {
if (edited) {
Icon(
imageVector = Icons.Rounded.Create,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall,
)
Spacer(modifier = Modifier.width(4.dp))
// val dateContainerWidth by animateDpAsState(
// targetValue = if (edited) 50.dp else 30.dp,
// label = "dateContainerWidth"
// )
if (isOut) {
Icon(
modifier = Modifier.size(14.dp),
painter = painterResource(
when (sendingStatus) {
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
SendingStatus.SENT -> {
if (isRead) UiR.drawable.round_done_all_24
else UiR.drawable.ic_round_done_24
}
// AnimatedVisibility(
// date != null,
// modifier = Modifier
// .width(dateContainerWidth)
// .align(Alignment.BottomEnd)
// ) {
// Row(modifier = Modifier.fillMaxWidth()) {
// if (edited) {
// Icon(
// imageVector = Icons.Rounded.Create,
// contentDescription = null,
// modifier = Modifier.size(14.dp)
// )
// Spacer(modifier = Modifier.width(4.dp))
// }
// Text(
// text = date.orEmpty(),
// style = MaterialTheme.typography.labelSmall
// )
// Spacer(modifier = Modifier.width(2.dp))
// }
// }
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
}
),
tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
else LocalContentColor.current,
contentDescription = null
)
}
}
}
}
@@ -159,7 +159,7 @@ fun MessagesHistoryScreen(
val listState = rememberLazyListState()
val paginationConditionMet by remember {
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
@@ -282,6 +282,7 @@ fun MessagesHistoryScreen(
// TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat)
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId
@@ -90,26 +90,28 @@ fun MessagesList(
if (item.isOut) {
OutgoingMessageBubble(
modifier =
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
} else {
IncomingMessageBubble(
modifier =
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
}
}
@@ -128,16 +130,17 @@ fun MessagesList(
}
}
Spacer(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
Spacer(Modifier.height(8.dp))
Spacer(
modifier = Modifier
.height(64.dp)
.fillMaxWidth()
)
Spacer(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
}
}
}
@@ -3,12 +3,8 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -20,6 +16,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
Row(
modifier = modifier.fillMaxWidth(),
@@ -37,18 +34,12 @@ fun OutgoingMessageBubble(
modifier = Modifier,
text = message.text.orDots(),
isOut = true,
date = null,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
)
if (message.showDate) {
Spacer(modifier = Modifier.height(2.dp))
Text(
modifier = Modifier.padding(end = 12.dp),
text = message.date,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
@@ -4,11 +4,13 @@ import dev.meloda.fast.messageshistory.model.UiItem
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 =
indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message =
first { it.id == messageId } as UiItem.Message
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
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.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkConversation
@@ -90,8 +91,8 @@ fun VkConversation.extractTitle(
}.parseString(resources).orDots()
fun VkMessage.asPresentation(
conversation: VkConversation,
resourceProvider: ResourceProvider,
showDate: Boolean,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
@@ -118,15 +119,19 @@ fun VkMessage.asPresentation(
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = showDate,
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null
isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
}
)
}
fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean {
if (isOut) return false
return nextMessage == null || nextMessage.fromId != fromId
+5 -1
View File
@@ -19,13 +19,16 @@ dependencyResolutionManagement {
rootProject.name = "fast-messenger"
include(":app")
include(":core:network")
include(":core:data")
include(":core:database")
include(":core:datastore")
include(":core:ui")
include(":core:common")
include(":core:domain")
include(":core:model")
include(":feature:messageshistory")
include(":feature:conversations")
include(":feature:auth")
@@ -35,4 +38,5 @@ include(":feature:photoviewer")
include(":feature:settings")
include(":feature:friends")
include(":feature:profile")
include(":core:domain")
include(":feature:createchat")
include(":core:presentation")