[wip] chat materials; some experiments with local composition and blur

This commit is contained in:
2024-07-12 00:51:24 +03:00
parent c43278e4cf
commit fb76b46b22
46 changed files with 1210 additions and 717 deletions
@@ -9,6 +9,7 @@ import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
@@ -16,19 +17,23 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -36,9 +41,16 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.meloda.app.fast.conversations.navigation.Conversations import com.meloda.app.fast.conversations.navigation.Conversations
import com.meloda.app.fast.conversations.navigation.conversationsRoute import com.meloda.app.fast.conversations.navigation.conversationsRoute
import com.meloda.app.fast.designsystem.LocalBottomPadding
import com.meloda.app.fast.designsystem.LocalHazeState
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.friends.navigation.Friends import com.meloda.app.fast.friends.navigation.Friends
import com.meloda.app.fast.friends.navigation.friendsRoute import com.meloda.app.fast.friends.navigation.friendsRoute
import com.meloda.app.fast.model.BaseError import com.meloda.app.fast.model.BaseError
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import com.meloda.app.fast.designsystem.R as UiR import com.meloda.app.fast.designsystem.R as UiR
@@ -58,7 +70,7 @@ data class BottomNavigationItem(
val route: Any, val route: Any,
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
fun NavGraphBuilder.mainScreen( fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
@@ -87,6 +99,8 @@ fun NavGraphBuilder.mainScreen(
val routes = items.map(BottomNavigationItem::route) val routes = items.map(BottomNavigationItem::route)
composable<Main> { composable<Main> {
val currentTheme = LocalTheme.current
val hazeState = remember { HazeState() }
val navController = rememberNavController() val navController = rememberNavController()
var selectedItemIndex by rememberSaveable { var selectedItemIndex by rememberSaveable {
@@ -104,7 +118,21 @@ fun NavGraphBuilder.mainScreen(
enter = slideIn { IntOffset(0, 400) }, enter = slideIn { IntOffset(0, 400) },
exit = slideOut { IntOffset(0, 400) } exit = slideOut { IntOffset(0, 400) }
) { ) {
NavigationBar { NavigationBar(
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
containerColor = NavigationBarDefaults.containerColor.copy(
alpha = if (currentTheme.usingBlur) 0f else 1f
)
) {
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
NavigationBarItem( NavigationBarItem(
selected = selectedItemIndex == index, selected = selectedItemIndex == index,
@@ -139,7 +167,11 @@ fun NavGraphBuilder.mainScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = padding.calculateBottomPadding()) .padding(bottom = if (currentTheme.usingBlur) 0.dp else padding.calculateBottomPadding())
) {
CompositionLocalProvider(
LocalHazeState provides hazeState,
LocalBottomPadding provides if (currentTheme.usingBlur) padding.calculateBottomPadding() else 0.dp
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -193,4 +225,5 @@ fun NavGraphBuilder.mainScreen(
} }
} }
} }
}
} }
@@ -6,6 +6,7 @@ import android.os.PowerManager
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.meloda.app.fast.MainViewModelImpl import com.meloda.app.fast.MainViewModelImpl
import com.meloda.app.fast.auth.authModule import com.meloda.app.fast.auth.authModule
import com.meloda.app.fast.chatmaterials.di.chatMaterialsModule
import com.meloda.app.fast.conversations.di.conversationsModule import com.meloda.app.fast.conversations.di.conversationsModule
import com.meloda.app.fast.data.di.dataModule import com.meloda.app.fast.data.di.dataModule
import com.meloda.app.fast.friends.di.friendsModule import com.meloda.app.fast.friends.di.friendsModule
@@ -32,7 +33,8 @@ val applicationModule = module {
languagePickerModule, languagePickerModule,
longPollModule, longPollModule,
friendsModule, friendsModule,
profileModule profileModule,
chatMaterialsModule
) )
// TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors // TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors
@@ -231,13 +231,13 @@ class LongPollUpdatesParser(
Log.d("LongPollUpdatesParser", "$eventType: $event") Log.d("LongPollUpdatesParser", "$eventType: $event")
} }
private suspend fun <T : LongPollEvent> loadNormalMessage( private suspend inline fun <reified T : LongPollEvent> loadNormalMessage(
eventType: ApiEvent, eventType: ApiEvent,
messageId: Int messageId: Int
): T? = suspendCoroutine { ): T? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById( messagesUseCase.getById(
messageId = messageId, messageIds = listOf(messageId),
extended = true, extended = true,
fields = VkConstants.ALL_FIELDS fields = VkConstants.ALL_FIELDS
).listenValue(this) { state -> ).listenValue(this) { state ->
@@ -245,8 +245,12 @@ class LongPollUpdatesParser(
error = { error -> error = { error ->
Log.e("LongPollUpdatesParser", "loadNormalMessage: error: $error") Log.e("LongPollUpdatesParser", "loadNormalMessage: error: $error")
}, },
success = { response -> success = { messages ->
response?.let { message -> val message = messages.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
VkMemoryCache[message.id] = message VkMemoryCache[message.id] = message
messagesUseCase.storeMessage(message) messagesUseCase.storeMessage(message)
@@ -254,11 +258,13 @@ class LongPollUpdatesParser(
ApiEvent.MESSAGE_NEW -> LongPollEvent.VkMessageNewEvent(message) ApiEvent.MESSAGE_NEW -> LongPollEvent.VkMessageNewEvent(message)
ApiEvent.MESSAGE_EDIT -> LongPollEvent.VkMessageEditEvent(message) ApiEvent.MESSAGE_EDIT -> LongPollEvent.VkMessageEditEvent(message)
else -> null else -> {
continuation.resume(null)
null
}
} }
resumeValue?.let { value -> it.resume(value as T) } resumeValue?.let { value -> continuation.resume(value as T) }
} ?: it.resume(null)
} }
) )
} }
@@ -0,0 +1,9 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkMessage
data class MessagesHistoryInfo(
val messages: List<VkMessage>,
val conversations: List<VkConversation>
)
@@ -1,16 +0,0 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.database.VkMessageEntity
interface MessagesLocalDataSource {
suspend fun getMessages(
conversationId: Int,
offset: Int?,
count: Int?
): List<VkMessageEntity>
suspend fun getMessage(messageId: Int): VkMessageEntity?
suspend fun storeMessages(messages: List<VkMessageEntity>)
}
@@ -1,24 +0,0 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.database.dao.MessageDao
import com.meloda.app.fast.model.database.VkMessageEntity
// TODO: 05/05/2024, Danil Nikolaev: use paging for room
class MessagesLocalDataSourceImpl(
private val messageDao: MessageDao
) : MessagesLocalDataSource {
override suspend fun getMessages(
conversationId: Int,
offset: Int?,
count: Int?
): List<VkMessageEntity> = messageDao.getAll(conversationId)
override suspend fun getMessage(
messageId: Int
): VkMessageEntity? = messageDao.getById(messageId)
override suspend fun storeMessages(messages: List<VkMessageEntity>) {
messageDao.insertAll(messages)
}
}
@@ -1,36 +0,0 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface MessagesNetworkDataSource {
suspend fun getMessagesHistory(
conversationId: Int,
offset: Int?,
count: Int?,
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain>
suspend fun getMessageById(
messagesIds: List<Int>,
extended: Boolean?,
fields: String?
): ApiResult<VkMessage, RestApiErrorDomain>
suspend fun send(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain>
suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessage(messageId: Int): VkMessage?
}
@@ -1,164 +0,0 @@
package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.data.VkGroupsMap
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.data.VkUsersMap
import com.meloda.app.fast.model.api.data.VkContactData
import com.meloda.app.fast.model.api.data.VkGroupData
import com.meloda.app.fast.model.api.data.VkUserData
import com.meloda.app.fast.model.api.data.asDomain
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.model.api.requests.MessagesGetByIdRequest
import com.meloda.app.fast.model.api.requests.MessagesGetHistoryRequest
import com.meloda.app.fast.model.api.requests.MessagesMarkAsReadRequest
import com.meloda.app.fast.model.api.requests.MessagesSendRequest
import com.meloda.app.fast.network.RestApiErrorDomain
import com.meloda.app.fast.network.mapApiDefault
import com.meloda.app.fast.network.mapApiResult
import com.meloda.app.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class MessagesNetworkDataSourceImpl(
private val messagesService: MessagesService
) : MessagesNetworkDataSource {
override suspend fun getMessagesHistory(
conversationId: Int,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryRequest(
count = count,
offset = offset,
peerId = conversationId,
extended = true,
startMessageId = null,
rev = null,
fields = VkConstants.ALL_FIELDS
)
messagesService.getHistory(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val messages = response.items.map { item ->
item.asDomain().let { message ->
message.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
}
val conversations = response.conversations.orEmpty().map { item ->
val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message)
.let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
MessagesHistoryDomain(
messages = messages,
conversations = conversations
)
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun getMessageById(
messagesIds: List<Int>,
extended: Boolean?,
fields: String?
): ApiResult<VkMessage, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetByIdRequest(
messagesIds = messagesIds,
extended = extended,
fields = fields
)
messagesService.getById(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val message = response.items.single()
val usersMap =
VkUsersMap.forUsers(response.profiles.orEmpty().map(VkUserData::mapToDomain))
val groupsMap =
VkGroupsMap.forGroups(response.groups.orEmpty().map(VkGroupData::mapToDomain))
message.asDomain().copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
)
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun send(
peerId: Int,
randomId: Int,
message: String?,
replyTo: Int?,
attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
attachments = attachments
)
messagesService.send(requestModel.map).mapApiDefault()
}
override suspend fun markAsRead(
peerId: Int,
startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesMarkAsReadRequest(
peerId = peerId,
startMessageId = startMessageId
)
messagesService.markAsRead(requestModel.map).mapApiDefault()
}
override suspend fun getMessage(messageId: Int): VkMessage? = withContext(Dispatchers.IO) {
// TODO: 05/05/2024, Danil Nikolaev: get message
null
}
}
data class MessagesHistoryDomain(
val messages: List<VkMessage>,
val conversations: List<VkConversation>
)
@@ -1,24 +1,24 @@
package com.meloda.app.fast.data.api.messages package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.model.api.domain.VkAttachment import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import com.meloda.app.fast.model.api.domain.VkMessage import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.network.RestApiErrorDomain import com.meloda.app.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import kotlinx.coroutines.flow.Flow
interface MessagesRepository { interface MessagesRepository {
suspend fun getMessagesHistory( suspend fun getHistory(
conversationId: Int, conversationId: Int,
offset: Int?, offset: Int?,
count: Int? count: Int?
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain> ): ApiResult<MessagesHistoryInfo, RestApiErrorDomain>
suspend fun getMessageById( suspend fun getById(
messagesIds: List<Int>, messagesIds: List<Int>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): ApiResult<VkMessage, RestApiErrorDomain> ): ApiResult<List<VkMessage>, RestApiErrorDomain>
suspend fun send( suspend fun send(
peerId: Int, peerId: Int,
@@ -33,14 +33,16 @@ interface MessagesRepository {
startMessageId: Int? startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain> ): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessage(messageId: Int): Flow<VkMessage?> suspend fun getHistoryAttachments(
peerId: Int,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
conversationMessageId: Int
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
// suspend fun getHistory(
// params: MessagesGetHistoryRequest
// ): ApiResult<MessagesGetHistoryResponse, RestApiErrorDomain>
// suspend fun markAsImportant( // suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest // params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain> // ): ApiResult<List<Int>, RestApiErrorDomain>
@@ -1,56 +1,132 @@
package com.meloda.app.fast.data.api.messages package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.data.VkGroupsMap
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.data.VkUsersMap
import com.meloda.app.fast.database.dao.MessageDao
import com.meloda.app.fast.model.api.data.VkAttachmentHistoryMessageData
import com.meloda.app.fast.model.api.data.VkContactData
import com.meloda.app.fast.model.api.data.VkGroupData
import com.meloda.app.fast.model.api.data.VkUserData
import com.meloda.app.fast.model.api.data.asDomain
import com.meloda.app.fast.model.api.domain.VkAttachment import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import com.meloda.app.fast.model.api.domain.VkMessage import com.meloda.app.fast.model.api.domain.VkMessage
import com.meloda.app.fast.model.api.domain.asEntity import com.meloda.app.fast.model.api.domain.asEntity
import com.meloda.app.fast.model.database.asExternalModel import com.meloda.app.fast.model.api.requests.MessagesGetByIdRequest
import com.meloda.app.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import com.meloda.app.fast.model.api.requests.MessagesGetHistoryRequest
import com.meloda.app.fast.model.api.requests.MessagesMarkAsReadRequest
import com.meloda.app.fast.model.api.requests.MessagesSendRequest
import com.meloda.app.fast.network.RestApiErrorDomain import com.meloda.app.fast.network.RestApiErrorDomain
import com.meloda.app.fast.network.mapApiDefault
import com.meloda.app.fast.network.mapApiResult
import com.meloda.app.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
// TODO: 05/05/2024, Danil Nikolaev: implement syncing
class MessagesRepositoryImpl( class MessagesRepositoryImpl(
private val networkDataSource: MessagesNetworkDataSource, private val messagesService: MessagesService,
private val localDataSource: MessagesLocalDataSource private val messageDao: MessageDao,
) : MessagesRepository { ) : MessagesRepository {
override suspend fun getMessagesHistory( override suspend fun getHistory(
conversationId: Int, conversationId: Int,
offset: Int?, offset: Int?,
count: Int? count: Int?
): ApiResult<MessagesHistoryDomain, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// val localMessages = localDataSource.getMessages( val requestModel = MessagesGetHistoryRequest(
// conversationId = conversationId, count = count,
// offset = offset, offset = offset,
// count = count peerId = conversationId,
// ).map(VkMessageEntity::asExternalModel) extended = true,
// startMessageId = null,
// emit(localMessages) rev = null,
// fields = VkConstants.ALL_FIELDS
// val networkMessages = networkDataSource.getMessagesHistory( )
// conversationId = conversationId,
// offset = offset,
// count = count
// )
//
// emit(networkMessages)
networkDataSource.getMessagesHistory(conversationId, offset, count) messagesService.getHistory(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val messages = response.items.map { item ->
item.asDomain().let { message ->
message.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
} }
override suspend fun getMessageById( val conversations = response.conversations.orEmpty().map { item ->
val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message)
.let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
MessagesHistoryInfo(
messages = messages,
conversations = conversations
)
},
errorMapper = { error ->
error?.toDomain()
}
)
}
override suspend fun getById(
messagesIds: List<Int>, messagesIds: List<Int>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): ApiResult<VkMessage, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<List<VkMessage>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
networkDataSource.getMessageById( val requestModel = MessagesGetByIdRequest(
messagesIds = messagesIds, messagesIds = messagesIds,
extended = extended, extended = extended,
fields = fields fields = fields
) )
messagesService.getById(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val messages = response.items
val usersMap =
VkUsersMap.forUsers(response.profiles.orEmpty().map(VkUserData::mapToDomain))
val groupsMap =
VkGroupsMap.forGroups(response.groups.orEmpty().map(VkGroupData::mapToDomain))
messages.map { message ->
message.asDomain().copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
)
}
},
errorMapper = { error -> error?.toDomain() }
)
} }
override suspend fun send( override suspend fun send(
@@ -60,54 +136,72 @@ class MessagesRepositoryImpl(
replyTo: Int?, replyTo: Int?,
attachments: List<VkAttachment>? attachments: List<VkAttachment>?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
networkDataSource.send( val requestModel = MessagesSendRequest(
peerId, peerId = peerId,
randomId, randomId = randomId,
message, message = message,
replyTo, replyTo = replyTo,
attachments attachments = attachments
) )
messagesService.send(requestModel.map).mapApiDefault()
} }
override suspend fun markAsRead( override suspend fun markAsRead(
peerId: Int, peerId: Int,
startMessageId: Int? startMessageId: Int?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
networkDataSource.markAsRead(peerId, startMessageId) val requestModel = MessagesMarkAsReadRequest(
peerId = peerId,
startMessageId = startMessageId
)
messagesService.markAsRead(requestModel.map).mapApiDefault()
} }
override suspend fun getMessage(messageId: Int): Flow<VkMessage?> = flow { override suspend fun getHistoryAttachments(
val localMessage = localDataSource.getMessage(messageId)?.asExternalModel() peerId: Int,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
conversationMessageId: Int
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryAttachmentsRequest(
peerId = peerId,
extended = true,
count = count,
offset = offset,
preserveOrder = true,
attachmentTypes = attachmentTypes,
conversationMessageId = conversationMessageId,
fields = VkConstants.ALL_FIELDS
)
emit(localMessage) messagesService.getHistoryAttachments(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val networkMessage = networkDataSource.getMessage(messageId) val profilesList = response.profiles.orEmpty().map(VkUserData::mapToDomain)
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
emit(networkMessage) VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
response.items.map(VkAttachmentHistoryMessageData::toDomain)
},
errorMapper = { error ->
error?.toDomain()
}
)
} }
override suspend fun storeMessages(messages: List<VkMessage>) { override suspend fun storeMessages(messages: List<VkMessage>) {
localDataSource.storeMessages(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
// override suspend fun getHistory(
// params: MessagesGetHistoryRequest
// ): ApiResult<MessagesGetHistoryResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.getHistory(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun send(
// params: MessagesSendRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.send(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun markAsImportant( // override suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest // params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain> = withContext(Dispatchers.IO) { // ): ApiResult<List<Int>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -153,24 +247,6 @@ class MessagesRepositoryImpl(
// ) // )
// } // }
// //
// override suspend fun getById(
// params: MessagesGetByIdRequest
// ): ApiResult<MessagesGetByIdResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.getById(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun markAsRead(
// params: MessagesMarkAsReadRequest
// ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.markAsRead(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun getChat( // override suspend fun getChat(
// params: MessagesGetChatRequest // params: MessagesGetChatRequest
// ): ApiResult<VkChatData, RestApiErrorDomain> = withContext(Dispatchers.IO) { // ): ApiResult<VkChatData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -199,3 +275,4 @@ class MessagesRepositoryImpl(
// ) // )
// } // }
} }
@@ -2,6 +2,7 @@ package com.meloda.app.fast.data.api.messages
import com.meloda.app.fast.data.State import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.domain.VkAttachment import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import com.meloda.app.fast.model.api.domain.VkMessage import com.meloda.app.fast.model.api.domain.VkMessage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -11,15 +12,9 @@ interface MessagesUseCase {
conversationId: Int, conversationId: Int,
count: Int?, count: Int?,
offset: Int? offset: Int?
): Flow<State<MessagesHistoryDomain>> ): Flow<State<MessagesHistoryInfo>>
fun getById( fun getById(
messageId: Int,
extended: Boolean?,
fields: String?
): Flow<State<VkMessage?>>
fun getByIds(
messageIds: List<Int>, messageIds: List<Int>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
@@ -38,6 +33,14 @@ interface MessagesUseCase {
startMessageId: Int startMessageId: Int
): Flow<State<Int>> ): Flow<State<Int>>
fun getHistoryAttachments(
peerId: Int,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
conversationMessageId: Int
): Flow<State<List<VkAttachmentHistoryMessage>>>
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
} }
@@ -15,10 +15,6 @@ import com.meloda.app.fast.data.api.friends.FriendsRepository
import com.meloda.app.fast.data.api.friends.FriendsRepositoryImpl import com.meloda.app.fast.data.api.friends.FriendsRepositoryImpl
import com.meloda.app.fast.data.api.longpoll.LongPollRepository import com.meloda.app.fast.data.api.longpoll.LongPollRepository
import com.meloda.app.fast.data.api.longpoll.LongPollRepositoryImpl import com.meloda.app.fast.data.api.longpoll.LongPollRepositoryImpl
import com.meloda.app.fast.data.api.messages.MessagesLocalDataSource
import com.meloda.app.fast.data.api.messages.MessagesLocalDataSourceImpl
import com.meloda.app.fast.data.api.messages.MessagesNetworkDataSource
import com.meloda.app.fast.data.api.messages.MessagesNetworkDataSourceImpl
import com.meloda.app.fast.data.api.messages.MessagesRepository import com.meloda.app.fast.data.api.messages.MessagesRepository
import com.meloda.app.fast.data.api.messages.MessagesRepositoryImpl import com.meloda.app.fast.data.api.messages.MessagesRepositoryImpl
import com.meloda.app.fast.data.api.oauth.OAuthRepository import com.meloda.app.fast.data.api.oauth.OAuthRepository
@@ -59,8 +55,6 @@ val dataModule = module {
singleOf(::LongPollRepositoryImpl) bind LongPollRepository::class singleOf(::LongPollRepositoryImpl) bind LongPollRepository::class
singleOf(::MessagesLocalDataSourceImpl) bind MessagesLocalDataSource::class
singleOf(::MessagesNetworkDataSourceImpl) bind MessagesNetworkDataSource::class
singleOf(::MessagesRepositoryImpl) bind MessagesRepository::class singleOf(::MessagesRepositoryImpl) bind MessagesRepository::class
singleOf(::OAuthRepositoryImpl) bind OAuthRepository::class singleOf(::OAuthRepositoryImpl) bind OAuthRepository::class
@@ -21,7 +21,7 @@ import com.meloda.app.fast.model.database.VkUserEntity
VkConversationEntity::class VkConversationEntity::class
], ],
version = 5 version = 6
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
@@ -18,12 +18,14 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.meloda.app.fast.datastore.isUsingAmoledBackground import com.meloda.app.fast.datastore.isUsingAmoledBackground
import com.meloda.app.fast.datastore.isUsingDynamicColors import com.meloda.app.fast.datastore.isUsingDynamicColors
import com.meloda.app.fast.datastore.model.ThemeConfig import com.meloda.app.fast.datastore.model.ThemeConfig
import com.meloda.app.fast.datastore.selectedColorScheme import com.meloda.app.fast.datastore.selectedColorScheme
import com.meloda.app.fast.designsystem.colorschemes.ClassicColorScheme import com.meloda.app.fast.designsystem.colorschemes.ClassicColorScheme
import dev.chrisbanes.haze.HazeState
private val googleSansFonts = FontFamily( private val googleSansFonts = FontFamily(
Font(resId = R.font.google_sans_regular), Font(resId = R.font.google_sans_regular),
@@ -115,6 +117,14 @@ val LocalTheme = compositionLocalOf {
) )
} }
val LocalHazeState = compositionLocalOf {
HazeState()
}
val LocalBottomPadding = compositionLocalOf {
0.dp
}
@Composable @Composable
fun AppTheme( fun AppTheme(
predefinedColorScheme: ColorScheme? = null, predefinedColorScheme: ColorScheme? = null,
@@ -0,0 +1,25 @@
package com.meloda.app.fast.model.api.data
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class VkAttachmentHistoryMessageData(
@Json(name = "message_id") val messageId: Int,
@Json(name = "date") val date: Int,
@Json(name = "cmid") val conversationMessageId: Int,
@Json(name = "from_id") val fromId: Int,
@Json(name = "position") val position: Int,
@Json(name = "attachment") val attachment: VkAttachmentItemData
) {
fun toDomain(): VkAttachmentHistoryMessage = VkAttachmentHistoryMessage(
messageId = messageId,
conversationMessageId = conversationMessageId,
date = date,
fromId = fromId,
position = position,
attachment = attachment.toDomain()
)
}
@@ -1,5 +1,7 @@
package com.meloda.app.fast.model.api.data package com.meloda.app.fast.model.api.data
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkUnknownAttachment
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@@ -12,7 +14,7 @@ data class VkAttachmentItemData(
@Json(name = "doc") val file: VkFileData?, @Json(name = "doc") val file: VkFileData?,
@Json(name = "link") val link: VkLinkData?, @Json(name = "link") val link: VkLinkData?,
@Json(name = "mini_app") val miniApp: VkMiniAppData?, @Json(name = "mini_app") val miniApp: VkMiniAppData?,
@Json(name = "audio_message") val voiceMessage: VkAudioMessageData?, @Json(name = "audio_message") val audioMessage: VkAudioMessageData?,
@Json(name = "sticker") val sticker: VkStickerData?, @Json(name = "sticker") val sticker: VkStickerData?,
@Json(name = "gift") val gift: VkGiftData?, @Json(name = "gift") val gift: VkGiftData?,
@Json(name = "wall") val wall: VkWallData?, @Json(name = "wall") val wall: VkWallData?,
@@ -20,7 +22,7 @@ data class VkAttachmentItemData(
@Json(name = "poll") val poll: VkPollData?, @Json(name = "poll") val poll: VkPollData?,
@Json(name = "wall_reply") val wallReply: VkWallReplyData?, @Json(name = "wall_reply") val wallReply: VkWallReplyData?,
@Json(name = "call") val call: VkCallData?, @Json(name = "call") val call: VkCallData?,
@Json(name = "group_call_in_progress") val groupCall: VkGroupCallData?, @Json(name = "group_call_in_progress") val groupCallInProgress: VkGroupCallData?,
@Json(name = "curator") val curator: VkCuratorData?, @Json(name = "curator") val curator: VkCuratorData?,
@Json(name = "event") val event: VkEventData?, @Json(name = "event") val event: VkEventData?,
@Json(name = "story") val story: VkStoryData?, @Json(name = "story") val story: VkStoryData?,
@@ -30,6 +32,29 @@ data class VkAttachmentItemData(
@Json(name = "audio_playlist") val audioPlaylist: VkAudioPlaylistData?, @Json(name = "audio_playlist") val audioPlaylist: VkAudioPlaylistData?,
@Json(name = "podcast") val podcast: VkPodcastData? @Json(name = "podcast") val podcast: VkPodcastData?
) { ) {
fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
fun getPreparedType(): AttachmentType = AttachmentType.parse(type) AttachmentType.UNKNOWN -> VkUnknownAttachment
AttachmentType.PHOTO -> photo?.toDomain()
AttachmentType.VIDEO -> video?.toDomain()
AttachmentType.AUDIO -> audio?.toDomain()
AttachmentType.FILE -> file?.toDomain()
AttachmentType.LINK -> link?.toDomain()
AttachmentType.MINI_APP -> miniApp?.toDomain()
AttachmentType.AUDIO_MESSAGE -> audioMessage?.toDomain()
AttachmentType.STICKER -> sticker?.toDomain()
AttachmentType.GIFT -> gift?.toDomain()
AttachmentType.WALL -> wall?.toDomain()
AttachmentType.GRAFFITI -> graffiti?.toDomain()
AttachmentType.POLL -> poll?.toDomain()
AttachmentType.WALL_REPLY -> wallReply?.toDomain()
AttachmentType.CALL -> call?.toDomain()
AttachmentType.GROUP_CALL_IN_PROGRESS -> groupCallInProgress?.toDomain()
AttachmentType.CURATOR -> curator?.toDomain()
AttachmentType.EVENT -> event?.toDomain()
AttachmentType.STORY -> story?.toDomain()
AttachmentType.WIDGET -> widget?.toDomain()
AttachmentType.ARTIST -> artist?.toDomain()
AttachmentType.AUDIO_PLAYLIST -> audioPlaylist?.toDomain()
AttachmentType.PODCAST -> podcast?.toDomain()
} ?: VkUnknownAttachment
} }
@@ -1,6 +1,5 @@
package com.meloda.app.fast.model.api.data package com.meloda.app.fast.model.api.data
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkMessage import com.meloda.app.fast.model.api.domain.VkMessage
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@@ -60,6 +59,7 @@ data class VkMessageData(
fun VkMessageData.asDomain(): VkMessage = VkMessage( fun VkMessageData.asDomain(): VkMessage = VkMessage(
id = id ?: -1, id = id ?: -1,
conversationMessageId = conversationMessageId,
text = text.ifBlank { null }, text = text.ifBlank { null },
isOut = out == 1, isOut = out == 1,
peerId = peerId ?: -1, peerId = peerId ?: -1,
@@ -75,134 +75,10 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
important = important, important = important,
updateTime = updateTime, updateTime = updateTime,
forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain), forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain),
attachments = parseAttachments(), attachments = attachments.map(VkAttachmentItemData::toDomain),
replyMessage = replyMessage?.asDomain(), replyMessage = replyMessage?.asDomain(),
user = null, user = null,
group = null, group = null,
actionUser = null, actionUser = null,
actionGroup = null, actionGroup = null,
) )
private fun VkMessageData.parseAttachments(): List<VkAttachment> {
if (attachments.isEmpty()) return emptyList()
val attachments = mutableListOf<VkAttachment>()
for (baseAttachment in this.attachments) {
when (baseAttachment.getPreparedType()) {
AttachmentType.UNKNOWN -> continue
AttachmentType.PHOTO -> {
val photo = baseAttachment.photo ?: continue
attachments += photo.toDomain()
}
AttachmentType.VIDEO -> {
val video = baseAttachment.video ?: continue
attachments += video.toDomain()
}
AttachmentType.AUDIO -> {
val audio = baseAttachment.audio ?: continue
attachments += audio.toDomain()
}
AttachmentType.FILE -> {
val file = baseAttachment.file ?: continue
attachments += file.toDomain()
}
AttachmentType.LINK -> {
val link = baseAttachment.link ?: continue
attachments += link.toDomain()
}
AttachmentType.MINI_APP -> {
val miniApp = baseAttachment.miniApp ?: continue
attachments += miniApp.toDomain()
}
AttachmentType.AUDIO_MESSAGE -> {
val voiceMessage = baseAttachment.voiceMessage ?: continue
attachments += voiceMessage.toDomain()
}
AttachmentType.STICKER -> {
val sticker = baseAttachment.sticker ?: continue
attachments += sticker.toDomain()
}
AttachmentType.GIFT -> {
val gift = baseAttachment.gift ?: continue
attachments += gift.toDomain()
}
AttachmentType.WALL -> {
val wall = baseAttachment.wall ?: continue
attachments += wall.toDomain()
}
AttachmentType.GRAFFITI -> {
val graffiti = baseAttachment.graffiti ?: continue
attachments += graffiti.toDomain()
}
AttachmentType.POLL -> {
val poll = baseAttachment.poll ?: continue
attachments += poll.toDomain()
}
AttachmentType.WALL_REPLY -> {
val wallReply = baseAttachment.wallReply ?: continue
attachments += wallReply.toDomain()
}
AttachmentType.CALL -> {
val call = baseAttachment.call ?: continue
attachments += call.toDomain()
}
AttachmentType.GROUP_CALL_IN_PROGRESS -> {
val groupCall = baseAttachment.groupCall ?: continue
attachments += groupCall.toDomain()
}
AttachmentType.CURATOR -> {
val curator = baseAttachment.curator ?: continue
attachments += curator.toDomain()
}
AttachmentType.EVENT -> {
val event = baseAttachment.event ?: continue
attachments += event.toDomain()
}
AttachmentType.STORY -> {
val story = baseAttachment.story ?: continue
attachments += story.toDomain()
}
AttachmentType.WIDGET -> {
val widget = baseAttachment.widget ?: continue
attachments += widget.toDomain()
}
AttachmentType.ARTIST -> {
val artist = baseAttachment.artist ?: continue
attachments += artist.toDomain()
val audios = baseAttachment.audios ?: continue
audios.map(VkAudioData::toDomain).let(attachments::addAll)
}
AttachmentType.AUDIO_PLAYLIST -> {
val audioPlaylist = baseAttachment.audioPlaylist ?: continue
attachments += audioPlaylist.toDomain()
}
AttachmentType.PODCAST -> {
val podcast = baseAttachment.podcast ?: continue
attachments += podcast.toDomain()
}
}
}
return attachments
}
@@ -28,6 +28,7 @@ data class VkPinnedMessageData(
fun mapToDomain(): VkMessage = VkMessage( fun mapToDomain(): VkMessage = VkMessage(
id = id ?: -1, id = id ?: -1,
conversationMessageId = conversationMessageId,
text = text.ifBlank { null }, text = text.ifBlank { null },
isOut = out == true, isOut = out == true,
peerId = peerId ?: -1, peerId = peerId ?: -1,
@@ -0,0 +1,10 @@
package com.meloda.app.fast.model.api.domain
data class VkAttachmentHistoryMessage(
val messageId: Int,
val conversationMessageId: Int,
val date: Int,
val fromId: Int,
val position: Int,
val attachment: VkAttachment
)
@@ -4,6 +4,7 @@ import com.meloda.app.fast.model.database.VkMessageEntity
data class VkMessage( data class VkMessage(
val id: Int, val id: Int,
val conversationMessageId: Int,
val text: String?, val text: String?,
val isOut: Boolean, val isOut: Boolean,
val peerId: Int, val peerId: Int,
@@ -78,6 +79,7 @@ data class VkMessage(
fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity( fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
id = id, id = id,
conversationMessageId = conversationMessageId,
text = text, text = text,
isOut = isOut, isOut = isOut,
peerId = peerId, peerId = peerId,
@@ -0,0 +1,7 @@
package com.meloda.app.fast.model.api.domain
import com.meloda.app.fast.model.api.data.AttachmentType
data object VkUnknownAttachment : VkAttachment {
override val type: AttachmentType = AttachmentType.UNKNOWN
}
@@ -243,3 +243,27 @@ data class MessagesRemoveChatUserRequest(
"member_id" to memberId.toString() "member_id" to memberId.toString()
) )
} }
data class MessagesGetHistoryAttachmentsRequest(
val peerId: Int,
val extended: Boolean?,
val count: Int?,
val offset: Int?,
val preserveOrder: Boolean?,
val attachmentTypes: List<String>,
val conversationMessageId: Int,
val fields: String?
) {
val map = mutableMapOf(
"peer_id" to peerId.toString(),
"attachment_types" to attachmentTypes.joinToString(","),
"cmid" to conversationMessageId.toString()
).apply {
extended?.let { this["extended"] = it.toString() }
count?.let { this["count"] = it.toString() }
offset?.let { this["offset"] = it.toString() }
preserveOrder?.let { this["preserve_order"] = it.toString() }
fields?.let { this["fields"] = it }
}
}
@@ -1,11 +1,13 @@
package com.meloda.app.fast.model.api.responses package com.meloda.app.fast.model.api.responses
import com.meloda.app.fast.model.api.data.VkAttachmentHistoryMessageData
import com.meloda.app.fast.model.api.data.VkChatMemberData import com.meloda.app.fast.model.api.data.VkChatMemberData
import com.meloda.app.fast.model.api.data.VkContactData import com.meloda.app.fast.model.api.data.VkContactData
import com.meloda.app.fast.model.api.data.VkConversationData import com.meloda.app.fast.model.api.data.VkConversationData
import com.meloda.app.fast.model.api.data.VkGroupData import com.meloda.app.fast.model.api.data.VkGroupData
import com.meloda.app.fast.model.api.data.VkMessageData import com.meloda.app.fast.model.api.data.VkMessageData
import com.meloda.app.fast.model.api.data.VkUserData import com.meloda.app.fast.model.api.data.VkUserData
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -33,3 +35,12 @@ data class MessagesGetConversationMembersResponse(
val profiles: List<VkUserData>?, val profiles: List<VkUserData>?,
val groups: List<VkGroupData>? val groups: List<VkGroupData>?
) )
@JsonClass(generateAdapter = true)
data class MessagesGetHistoryAttachmentsResponse(
@Json(name = "items") val items: List<VkAttachmentHistoryMessageData>,
@Json(name = "next_from") val nextFrom: String?,
@Json(name = "profiles") val profiles: List<VkUserData>?,
@Json(name = "groups") val groups: List<VkGroupData>?,
@Json(name = "contacts") val contacts: List<VkContactData>?
)
@@ -8,6 +8,7 @@ import com.meloda.app.fast.model.api.domain.VkMessage
@Entity(tableName = "messages") @Entity(tableName = "messages")
data class VkMessageEntity( data class VkMessageEntity(
@PrimaryKey val id: Int, @PrimaryKey val id: Int,
val conversationMessageId: Int,
val text: String?, val text: String?,
val isOut: Boolean, val isOut: Boolean,
val peerId: Int, val peerId: Int,
@@ -29,6 +30,7 @@ data class VkMessageEntity(
fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage( fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
id = id, id = id,
conversationMessageId = conversationMessageId,
text = text, text = text,
isOut = isOut, isOut = isOut,
peerId = peerId, peerId = peerId,
@@ -2,6 +2,7 @@ package com.meloda.app.fast.network.service.messages
import com.meloda.app.fast.model.api.data.VkLongPollData import com.meloda.app.fast.model.api.data.VkLongPollData
import com.meloda.app.fast.model.api.responses.MessagesGetByIdResponse import com.meloda.app.fast.model.api.responses.MessagesGetByIdResponse
import com.meloda.app.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import com.meloda.app.fast.model.api.responses.MessagesGetHistoryResponse import com.meloda.app.fast.model.api.responses.MessagesGetHistoryResponse
import com.meloda.app.fast.network.ApiResponse import com.meloda.app.fast.network.ApiResponse
import com.meloda.app.fast.network.RestApiError import com.meloda.app.fast.network.RestApiError
@@ -42,6 +43,12 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError> ): ApiResult<ApiResponse<Int>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.GET_HISTORY_ATTACHMENTS)
suspend fun getHistoryAttachments(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError>
// @FormUrlEncoded // @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant) // @POST(MessagesUrls.MarkAsImportant)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -18,4 +18,5 @@ object MessagesUrls {
const val GET_CHAT = "${AppConstants.URL_API}/messages.getChat" const val GET_CHAT = "${AppConstants.URL_API}/messages.getChat"
const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers" const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers"
const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser" const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments"
} }
+1
View File
@@ -27,6 +27,7 @@ android {
} }
kotlinOptions { kotlinOptions {
jvmTarget = Configs.java.toString() jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -1,91 +0,0 @@
package com.meloda.app.fast.chatmaterials
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun ChatMaterialsScreen(
onBack: () -> Unit
) {
val hazeState = remember { HazeState() }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Chat Materials")
},
colors = TopAppBarDefaults.largeTopAppBarColors(Color.Transparent),
modifier = Modifier
.hazeChild(
state = hazeState,
style = HazeMaterials.ultraThin()
)
.fillMaxWidth(),
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
}
)
}
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(200.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.haze(
state = hazeState,
style = HazeMaterials.ultraThin()
)
) {
items(100) { index ->
val link = "https://random.imagecdn.app/500/150"
AsyncImage(
model = link,
contentDescription = "Image",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
)
}
}
}
}
@@ -0,0 +1,130 @@
package com.meloda.app.fast.chatmaterials
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.meloda.app.fast.chatmaterials.model.ChatMaterialsScreenState
import com.meloda.app.fast.chatmaterials.navigation.ChatMaterials
import com.meloda.app.fast.chatmaterials.util.asPresentation
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
interface ChatMaterialsViewModel {
val screenState: StateFlow<ChatMaterialsScreenState>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onRefresh()
fun onErrorConsumed()
fun onTypeChanged(newType: String)
}
class ChatMaterialsViewModelImpl(
private val messagesUseCase: MessagesUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel(), ChatMaterialsViewModel {
override val screenState = MutableStateFlow(ChatMaterialsScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
init {
val arguments = ChatMaterials.from(savedStateHandle)
screenState.setValue { old ->
old.copy(
peerId = arguments.peerId,
conversationMessageId = arguments.conversationMessageId
)
}
loadChatMaterials()
}
override fun onMetPaginationCondition() {
currentOffset.update { screenState.value.materials.size }
loadChatMaterials()
}
override fun onRefresh() {
loadChatMaterials(offset = 0)
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
override fun onTypeChanged(newType: String) {
screenState.setValue { old -> old.copy(attachmentType = newType) }
loadChatMaterials(0)
}
private fun loadChatMaterials(
offset: Int = currentOffset.value
) {
messagesUseCase.getHistoryAttachments(
peerId = screenState.value.peerId,
count = LOAD_COUNT,
offset = offset,
attachmentTypes = listOf(screenState.value.attachmentType),
conversationMessageId = screenState.value.conversationMessageId
).listenValue { state ->
state.processState(
error = { error ->
},
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.materials.size >= LOAD_COUNT
val loadedMaterials = response.map(VkAttachmentHistoryMessage::asPresentation)
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
screenState.setValue {
newState.copy(materials = loadedMaterials)
}
} else {
screenState.setValue {
newState.copy(
materials = newState.materials.plus(loadedMaterials)
)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
companion object {
const val LOAD_COUNT = 100
}
}
@@ -0,0 +1,9 @@
package com.meloda.app.fast.chatmaterials.di
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModelImpl
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.dsl.module
val chatMaterialsModule = module {
viewModelOf(::ChatMaterialsViewModelImpl)
}
@@ -0,0 +1,24 @@
package com.meloda.app.fast.chatmaterials.model
data class ChatMaterialsScreenState(
val isLoading: Boolean,
val materials: List<UiChatMaterial>,
val attachmentType: String,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val peerId: Int,
val conversationMessageId: Int
) {
companion object {
val EMPTY: ChatMaterialsScreenState = ChatMaterialsScreenState(
isLoading = true,
materials = emptyList(),
attachmentType = "photo",
isPaginating = false,
isPaginationExhausted = false,
peerId = -1,
conversationMessageId = -1
)
}
}
@@ -0,0 +1,28 @@
package com.meloda.app.fast.chatmaterials.model
sealed class UiChatMaterial {
data class Photo(
val previewUrl: String
) : UiChatMaterial()
data class Video(
val previewUrl: String
) : UiChatMaterial()
data class Audio(
val previewUrl: String?,
val title: String,
val artist: String,
val duration: String
) : UiChatMaterial()
data class File(
val title: String
) : UiChatMaterial()
data class Link(
val title: String,
val previewUrl: String?
) : UiChatMaterial()
}
@@ -1,13 +1,23 @@
package com.meloda.app.fast.chatmaterials.navigation package com.meloda.app.fast.chatmaterials.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.meloda.app.fast.chatmaterials.ChatMaterialsScreen import androidx.navigation.toRoute
import com.meloda.app.fast.chatmaterials.presentation.ChatMaterialsScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ChatMaterials(val a: String) data class ChatMaterials(
val peerId: Int,
val conversationMessageId: Int
) {
companion object {
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<ChatMaterials>()
}
}
fun NavGraphBuilder.chatMaterialsRoute( fun NavGraphBuilder.chatMaterialsRoute(
onBack: () -> Unit onBack: () -> Unit
@@ -19,6 +29,11 @@ fun NavGraphBuilder.chatMaterialsRoute(
} }
} }
fun NavController.navigateToChatMaterials() { fun NavController.navigateToChatMaterials(peerId: Int, conversationMessageId: Int) {
this.navigate(ChatMaterials("")) this.navigate(
ChatMaterials(
peerId = peerId,
conversationMessageId = conversationMessageId
)
)
} }
@@ -0,0 +1,68 @@
package com.meloda.app.fast.chatmaterials.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
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.layout.ContentScale
import coil.ImageLoader
import coil.compose.AsyncImage
import com.meloda.app.fast.chatmaterials.model.UiChatMaterial
@Composable
fun ChatMaterialItem(
item: UiChatMaterial,
imageLoader: ImageLoader
) {
when (item) {
is UiChatMaterial.Photo -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
imageLoader = imageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
}
is UiChatMaterial.Video -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
imageLoader = imageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
}
is UiChatMaterial.Audio -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge
)
Text(text = item.artist)
}
Text(text = item.duration)
}
}
is UiChatMaterial.File -> {}
is UiChatMaterial.Link -> {}
}
}
@@ -0,0 +1,352 @@
package com.meloda.app.fast.chatmaterials.presentation
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
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.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModel
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModelImpl
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.R
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.koin.androidx.compose.koinViewModel
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun ChatMaterialsScreen(
onBack: () -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) {
val currentTheme = LocalTheme.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val attachments = screenState.materials
val imageLoader = LocalContext.current.imageLoader
var moreClearBlur by rememberSaveable {
mutableStateOf(false)
}
val hazeState = remember { HazeState() }
val hazeStyle = if (moreClearBlur) HazeMaterials.ultraThin() else HazeMaterials.regular()
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
var checkedTypeIndex by rememberSaveable {
mutableIntStateOf(0)
}
LaunchedEffect(checkedTypeIndex) {
viewModel.onTypeChanged(
when (checkedTypeIndex) {
0 -> "photo"
1 -> "video"
2 -> "audio"
3 -> "doc"
4 -> "link"
else -> ""
}
)
}
val titles = listOf("Photos", "Videos", "Audios", "Files", "Links")
val listState = rememberLazyListState()
val gridState = rememberLazyGridState()
val canScrollBackward = when (checkedTypeIndex) {
in 0..1 -> gridState.canScrollBackward
else -> listState.canScrollBackward
}
Log.d("ChatMaterialsScreen", "ChatMaterialsScreen: canScrollBackward: $canScrollBackward")
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.usingBlur || !canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val pullToRefreshAlpha by animateFloatAsState(
targetValue = if (!canScrollBackward) 1f else 0f,
label = "pullToRefreshAlpha",
animationSpec = tween(durationMillis = 50)
)
val pullToRefreshState = rememberPullToRefreshState()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Chat Materials")
},
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = hazeStyle
)
} else Modifier
)
.fillMaxWidth(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = if (currentTheme.usingBlur) toolbarColorAlpha else 1f
)
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
actions = {
IconButton(
onClick = {
dropDownMenuExpanded = true
}
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options button"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
viewModel.onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = R.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
if (currentTheme.usingBlur) {
DropdownMenuItem(
text = {
Text(text = if (moreClearBlur) "Default blur" else "Clearer blur")
},
onClick = {
moreClearBlur = !moreClearBlur
dropDownMenuExpanded = false
}
)
}
HorizontalDivider()
titles.forEachIndexed { index, title ->
DropdownMenuItem(
leadingIcon = {
RadioButton(
selected = checkedTypeIndex == index,
onClick = null
)
},
text = {
Text(text = title)
},
onClick = {
checkedTypeIndex = index
dropDownMenuExpanded = false
}
)
}
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
if (checkedTypeIndex in listOf(0, 1)) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.haze(
state = hazeState,
style = hazeStyle
)
} else {
Modifier
}
)
.fillMaxSize()
) {
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
imageLoader = imageLoader
)
}
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.haze(
state = hazeState,
style = hazeStyle
)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
imageLoader = imageLoader
)
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
}
}
LaunchedEffect(screenState.isLoading) {
if (!screenState.isLoading) {
pullToRefreshState.endRefresh()
}
}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier
.alpha(pullToRefreshAlpha)
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
contentColor = MaterialTheme.colorScheme.primary
)
}
}
}
@@ -0,0 +1,59 @@
package com.meloda.app.fast.chatmaterials.util
import com.meloda.app.fast.chatmaterials.model.UiChatMaterial
import com.meloda.app.fast.model.api.data.AttachmentType
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import com.meloda.app.fast.model.api.domain.VkAudioDomain
import com.meloda.app.fast.model.api.domain.VkFileDomain
import com.meloda.app.fast.model.api.domain.VkLinkDomain
import com.meloda.app.fast.model.api.domain.VkPhotoDomain
import com.meloda.app.fast.model.api.domain.VkVideoDomain
import java.text.SimpleDateFormat
import java.util.Locale
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
when (val type = this.attachment.type) {
AttachmentType.PHOTO -> {
val attachment = this.attachment as VkPhotoDomain
UiChatMaterial.Photo(
previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty()
)
}
AttachmentType.VIDEO -> {
val attachment = this.attachment as VkVideoDomain
UiChatMaterial.Video(
previewUrl = attachment.images.firstOrNull()?.url.orEmpty()
)
}
AttachmentType.AUDIO -> {
val attachment = this.attachment as VkAudioDomain
UiChatMaterial.Audio(
previewUrl = null,
title = attachment.title,
artist = attachment.artist,
duration = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(attachment.duration)
)
}
AttachmentType.FILE -> {
val attachment = this.attachment as VkFileDomain
UiChatMaterial.File(
title = attachment.title
)
}
AttachmentType.LINK -> {
val attachment = this.attachment as VkLinkDomain
UiChatMaterial.Link(
title = attachment.title ?: attachment.url,
previewUrl = attachment.photo?.getMaxSize()?.url
)
}
else -> throw IllegalArgumentException("Unsupported type: $type")
}
@@ -27,6 +27,7 @@ import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.conversations.model.ConversationOption import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.ConversationsScreenState import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.LocalBottomPadding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -45,6 +46,8 @@ fun ConversationsListComposable(
val conversations = screenState.conversations val conversations = screenState.conversations
val bottomPadding = LocalBottomPadding.current
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = state state = state
@@ -105,5 +108,9 @@ fun ConversationsListComposable(
} }
} }
} }
item {
Spacer(modifier = Modifier.height(bottomPadding))
}
} }
} }
@@ -5,16 +5,20 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
@@ -61,6 +65,7 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader import coil.imageLoader
@@ -70,6 +75,8 @@ import com.meloda.app.fast.conversations.ConversationsViewModel
import com.meloda.app.fast.conversations.ConversationsViewModelImpl import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.model.ConversationsScreenState import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.LocalBottomPadding
import com.meloda.app.fast.designsystem.LocalHazeState
import com.meloda.app.fast.designsystem.LocalTheme import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.MaterialDialog import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.components.FullScreenLoader import com.meloda.app.fast.designsystem.components.FullScreenLoader
@@ -142,7 +149,8 @@ fun ConversationsScreen(
} }
} }
val hazeState = remember { HazeState() } // val hazeState = remember { HazeState() }
val hazeState = LocalHazeState.current
var dropDownMenuExpanded by remember { var dropDownMenuExpanded by remember {
mutableStateOf(false) mutableStateOf(false)
@@ -252,11 +260,12 @@ fun ConversationsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) } val rotation = remember { Animatable(0f) }
Column {
AnimatedVisibility( AnimatedVisibility(
visible = isListScrollingUp, visible = isListScrollingUp,
modifier = Modifier.navigationBarsPadding(), modifier = Modifier.navigationBarsPadding(),
enter = slideIn { IntOffset(0, 400) }, enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 400) } exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
@@ -285,6 +294,9 @@ fun ConversationsScreen(
) )
} }
} }
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
} }
) { padding -> ) { padding ->
when { when {
@@ -77,7 +77,7 @@ class FriendsViewModelImpl(
} }
private fun loadFriends(offset: Int = currentOffset.value) { private fun loadFriends(offset: Int = currentOffset.value) {
friendsUseCase.getAllFriends(count = 30, offset = offset).listenValue { state -> friendsUseCase.getAllFriends(count = LOAD_COUNT, offset = offset).listenValue { state ->
state.processState( state.processState(
error = { error -> error = { error ->
when (error) { when (error) {
@@ -103,11 +103,11 @@ class FriendsViewModelImpl(
}, },
success = { info -> success = { info ->
val response = info.friends val response = info.friends
val itemsCountSufficient = response.size == 30 val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient &&
screenState.value.friends.size >= 30 screenState.value.friends.size >= LOAD_COUNT
imagesToPreload.setValue { imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100) response.mapNotNull(VkUser::photo100)
@@ -172,4 +172,8 @@ class FriendsViewModelImpl(
} }
uiOnlineFriends.setValue { onlineUiFriends } uiOnlineFriends.setValue { onlineUiFriends }
} }
companion object {
const val LOAD_COUNT = 30
}
} }
@@ -354,6 +354,7 @@ class MessagesHistoryViewModelImpl(
val newMessage = VkMessage( val newMessage = VkMessage(
id = -1 - sendingMessages.size, id = -1 - sendingMessages.size,
conversationMessageId = -1,
text = lastMessageText, text = lastMessageText,
isOut = true, isOut = true,
peerId = screenState.value.conversationId, peerId = screenState.value.conversationId,
@@ -1,27 +1,28 @@
package com.meloda.app.fast.messageshistory.domain package com.meloda.app.fast.messageshistory.domain
import com.meloda.app.fast.data.State import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.messages.MessagesHistoryDomain import com.meloda.app.fast.data.api.messages.MessagesHistoryInfo
import com.meloda.app.fast.data.api.messages.MessagesRepository import com.meloda.app.fast.data.api.messages.MessagesRepository
import com.meloda.app.fast.data.api.messages.MessagesUseCase import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.data.mapToState import com.meloda.app.fast.data.mapToState
import com.meloda.app.fast.model.api.domain.VkAttachment import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkAttachmentHistoryMessage
import com.meloda.app.fast.model.api.domain.VkMessage import com.meloda.app.fast.model.api.domain.VkMessage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
class MessagesUseCaseImpl( class MessagesUseCaseImpl(
private val messagesRepository: MessagesRepository private val repository: MessagesRepository
) : MessagesUseCase { ) : MessagesUseCase {
override fun getMessagesHistory( override fun getMessagesHistory(
conversationId: Int, conversationId: Int,
count: Int?, count: Int?,
offset: Int? offset: Int?
): Flow<State<MessagesHistoryDomain>> = flow { ): Flow<State<MessagesHistoryInfo>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = messagesRepository.getMessagesHistory( val newState = repository.getHistory(
conversationId = conversationId, conversationId = conversationId,
offset = offset, offset = offset,
count = count count = count
@@ -31,60 +32,20 @@ class MessagesUseCaseImpl(
} }
override fun getById( override fun getById(
messageId: Int,
extended: Boolean?,
fields: String?
): Flow<State<VkMessage?>> = flow {
emit(State.Loading)
val newState = messagesRepository.getMessageById(
messagesIds = listOf(messageId),
extended = extended,
fields = fields
).mapToState()
emit(newState)
}
override fun getByIds(
messageIds: List<Int>, messageIds: List<Int>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): Flow<State<List<VkMessage>>> = flow {} ): Flow<State<List<VkMessage>>> = flow {
// flow { emit(State.Loading)
// emit(State.Loading)
// val newState = repository.getById(
// val newState = messagesRepository.getById( messagesIds = messageIds,
// params = MessagesGetByIdRequest( extended = extended,
// messagesIds = messageIds, fields = fields
// extended = extended, ).mapToState()
// fields = fields
// ) emit(newState)
// ).fold( }
// onSuccess = { response ->
// val messages = response.items
// val usersMap =
// VkUsersMap.forUsers(response.profiles.orEmpty().map(VkUserData::mapToDomain))
// val groupsMap =
// VkGroupsMap.forGroups(response.groups.orEmpty().map(VkGroupData::mapToDomain))
//
// com.meloda.app.fast.network.State.Success(
// messages.map { message ->
// message.mapToDomain(
// user = usersMap.messageUser(message),
// group = groupsMap.messageGroup(message),
// actionUser = usersMap.messageActionUser(message),
// actionGroup = groupsMap.messageActionGroup(message)
// )
// }
// )
// },
// onNetworkFailure = { com.meloda.app.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
override fun sendMessage( override fun sendMessage(
peerId: Int, peerId: Int,
@@ -95,7 +56,7 @@ class MessagesUseCaseImpl(
): Flow<State<Int>> = flow { ): Flow<State<Int>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = messagesRepository.send( val newState = repository.send(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
@@ -112,7 +73,7 @@ class MessagesUseCaseImpl(
): Flow<State<Int>> = flow { ): Flow<State<Int>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = messagesRepository.markAsRead( val newState = repository.markAsRead(
peerId = peerId, peerId = peerId,
startMessageId = startMessageId startMessageId = startMessageId
).mapToState() ).mapToState()
@@ -120,11 +81,31 @@ class MessagesUseCaseImpl(
emit(newState) emit(newState)
} }
override fun getHistoryAttachments(
peerId: Int,
count: Int?,
offset: Int?,
attachmentTypes: List<String>,
conversationMessageId: Int
): Flow<State<List<VkAttachmentHistoryMessage>>> = flow {
emit(State.Loading)
val newState = repository.getHistoryAttachments(
peerId = peerId,
count = count,
offset = offset,
attachmentTypes = attachmentTypes,
conversationMessageId = conversationMessageId
).mapToState()
emit(newState)
}
override suspend fun storeMessage(message: VkMessage) { override suspend fun storeMessage(message: VkMessage) {
messagesRepository.storeMessages(listOf(message)) repository.storeMessages(listOf(message))
} }
override suspend fun storeMessages(messages: List<VkMessage>) { override suspend fun storeMessages(messages: List<VkMessage>) {
messagesRepository.storeMessages(messages) repository.storeMessages(messages)
} }
} }
@@ -4,6 +4,7 @@ import com.meloda.app.fast.common.UiImage
data class UiMessage( data class UiMessage(
val id: Int, val id: Int,
val conversationMessageId: Int,
val text: String?, val text: String?,
val isOut: Boolean, val isOut: Boolean,
val fromId: Int, val fromId: Int,
@@ -40,7 +40,7 @@ val MessagesHistoryNavType = object : NavType<MessagesHistoryArguments>(isNullab
fun NavGraphBuilder.messagesHistoryRoute( fun NavGraphBuilder.messagesHistoryRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToChatAttachments: () -> Unit onNavigateToChatAttachments: (peerId: Int, conversationMessageId: Int) -> Unit
) { ) {
composable<MessagesHistory>( composable<MessagesHistory>(
typeMap = mapOf(typeOf<MessagesHistoryArguments>() to MessagesHistoryNavType) typeMap = mapOf(typeOf<MessagesHistoryArguments>() to MessagesHistoryNavType)
@@ -90,7 +90,7 @@ import com.meloda.app.fast.designsystem.R as UiR
fun MessagesHistoryScreen( fun MessagesHistoryScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToChatMaterials: () -> Unit, onNavigateToChatMaterials: (peerId: Int, conversationMessageId: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>() viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) { ) {
val view = LocalView.current val view = LocalView.current
@@ -215,7 +215,12 @@ fun MessagesHistoryScreen(
DropdownMenuItem( DropdownMenuItem(
onClick = { onClick = {
dropDownMenuExpanded = false dropDownMenuExpanded = false
onNavigateToChatMaterials()
// TODO: 11/07/2024, Danil Nikolaev: to VM
onNavigateToChatMaterials(
screenState.conversationId,
screenState.messages.first().conversationMessageId
)
}, },
text = { text = {
Text(text = "Materials") Text(text = "Materials")
@@ -91,6 +91,7 @@ fun VkMessage.asPresentation(
nextMessage: VkMessage? nextMessage: VkMessage?
): UiMessage = UiMessage( ): UiMessage = UiMessage(
id = id, id = id,
conversationMessageId = conversationMessageId,
text = text, text = text,
isOut = isOut, isOut = isOut,
fromId = fromId, fromId = fromId,
+2 -3
View File
@@ -2,7 +2,7 @@
agp = "8.5.0" agp = "8.5.0"
converterMoshi = "2.11.0" converterMoshi = "2.11.0"
eithernet = "1.9.0" eithernet = "1.9.0"
haze = "0.7.2" haze = "0.7.3"
kotlin = "2.0.0" kotlin = "2.0.0"
ksp = "2.0.0-1.0.22" ksp = "2.0.0-1.0.22"
@@ -26,7 +26,7 @@ nanokt = "1.2.0"
junitVersion = "1.2.1" junitVersion = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.6.1"
appcompat = "1.7.0" appcompat = "1.7.0"
androidx-navigation = "2.8.0-beta04" androidx-navigation = "2.8.0-beta05"
serialization = "1.7.1" serialization = "1.7.1"
[libraries] [libraries]
@@ -47,7 +47,6 @@ compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
eithernet = { module = "com.slack.eithernet:eithernet", version.ref = "eithernet" } eithernet = { module = "com.slack.eithernet:eithernet", version.ref = "eithernet" }
eithernet-retrofit-integration = { module = "com.slack.eithernet:eithernet-integration-retrofit", version.ref = "eithernet" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }