Simple attachments in messages history (#164)

* new attachments in messages history - photo, video, audio, file, link
* improve attachments in messages history and adjusted font size for logo's text in auth screen
* audio duration, file preview and url preview are now visible in attachments in messages history screen
* make MessageBubble width adapt to attachments container width
* topbar back icon crossfade animation
* implement rich text for message input
* handle click and long click on attachments
* added click and long click handlers for attachments in message bubbles
* enabled opening photos, files, and links when clicked.
* implemented basic long-click logging for photos.
* handled back press to return to Conversations from other tabs.
* corrected the logic for filtering and selecting video images.
* updated string resources for attachments, including a new "Clip" string.
* make MessageBubble mention text underline on out messages
This commit is contained in:
2025-05-10 03:10:07 +03:00
committed by GitHub
parent f45a106ed8
commit 43539139e8
46 changed files with 2296 additions and 369 deletions
@@ -1,5 +1,6 @@
package dev.meloda.fast.presentation package dev.meloda.fast.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -37,6 +38,7 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.navigation.Conversations import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.conversations.navigation.conversationsGraph import dev.meloda.fast.conversations.navigation.conversationsGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.friends.navigation.friendsScreen
@@ -71,6 +73,18 @@ fun MainScreen(
mutableIntStateOf(1) mutableIntStateOf(1)
} }
BackHandler(enabled = selectedItemIndex != 1) {
val index = 1
val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = 1
navController.navigate(navigationItems[index].route) {
popUpTo(route = currentRoute) {
inclusive = true
}
}
}
val user = LocalUser.current val user = LocalUser.current
val profileImageUrl by remember(user) { val profileImageUrl by remember(user) {
derivedStateOf { user?.photo100 } derivedStateOf { user?.photo100 }
@@ -195,7 +209,7 @@ fun MainScreen(
onNavigateToCreateChat = onNavigateToCreateChat, onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = { onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also { tabReselected = tabReselected.toMutableMap().also {
it[Conversations] = false it[ConversationsGraph] = false
} }
} }
) )
@@ -23,6 +23,20 @@ fun <T> MutableList<T>.addIf(element: T, condition: () -> Boolean) {
if (condition.invoke()) add(element) if (condition.invoke()) add(element)
} }
fun <T> MutableList<T>.removeIfCompat(condition: (T) -> Boolean): Boolean {
var removed = false
val each = iterator()
while (each.hasNext()) {
if (condition(each.next())) {
each.remove()
removed = true
}
}
return removed
}
fun <T> Flow<T>.listenValue( fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
action: suspend (T) -> Unit action: suspend (T) -> Unit
@@ -33,7 +33,8 @@ interface MessagesRepository {
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, replyTo: Long?,
attachments: List<VkAttachment>? attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> ): ApiResult<MessagesSendResponse, RestApiErrorDomain>
suspend fun markAsRead( suspend fun markAsRead(
@@ -184,14 +184,16 @@ class MessagesRepositoryImpl(
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, replyTo: Long?,
attachments: List<VkAttachment>? attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest( val requestModel = MessagesSendRequest(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, replyTo = replyTo,
attachments = attachments attachments = attachments,
formatData = formatData
) )
messagesService.send(requestModel.map).mapApiDefault() messagesService.send(requestModel.map).mapApiDefault()
@@ -0,0 +1,58 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "ca007bca2ab4a9b901662792042770ad",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, `exchangeToken` TEXT, PRIMARY KEY(`userId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fastToken",
"columnName": "fastToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "trustedHash",
"columnName": "trustedHash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "exchangeToken",
"columnName": "exchangeToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca007bca2ab4a9b901662792042770ad')"
]
}
}
@@ -0,0 +1,454 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "fa307a5eb2e1f7d601bd1374174635cd",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversationMessageId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationMessageId",
"columnName": "conversationMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "actionConversationMessageId",
"columnName": "actionConversationMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "important",
"columnName": "important",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastConversationMessageId",
"columnName": "lastConversationMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fa307a5eb2e1f7d601bd1374174635cd')"
]
}
}
@@ -33,7 +33,8 @@ interface MessagesUseCase : BaseUseCase {
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, replyTo: Long?,
attachments: List<VkAttachment>? attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> ): Flow<State<MessagesSendResponse>>
fun markAsRead( fun markAsRead(
@@ -57,14 +57,16 @@ class MessagesUseCaseImpl(
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, replyTo: Long?,
attachments: List<VkAttachment>? attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState { ): Flow<State<MessagesSendResponse>> = flowNewState {
repository.send( repository.send(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, replyTo = replyTo,
attachments = attachments attachments = attachments,
formatData = formatData
).mapToState() ).mapToState()
} }
@@ -0,0 +1,8 @@
package dev.meloda.fast.model
data class PhotoSize(
val height: Int,
val width: Int,
val type: String,
val url: String
)
@@ -6,8 +6,8 @@ enum class AttachmentType(var value: String) {
UNKNOWN("unknown"), UNKNOWN("unknown"),
PHOTO("photo"), PHOTO("photo"),
VIDEO("video"), VIDEO("video"),
AUDIO("audio"),
FILE("doc"), FILE("doc"),
AUDIO("audio"),
LINK("link"), LINK("link"),
AUDIO_MESSAGE("audio_message"), AUDIO_MESSAGE("audio_message"),
MINI_APP("mini_app"), MINI_APP("mini_app"),
@@ -27,7 +27,9 @@ data class VkFileData(
) { ) {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Photo(val sizes: List<Size>) { data class Photo(
val sizes: List<Size>
) {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Size( data class Size(
@@ -2,6 +2,7 @@ package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.PhotoSize
import dev.meloda.fast.model.api.domain.VkPhotoDomain import dev.meloda.fast.model.api.domain.VkPhotoDomain
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -35,7 +36,14 @@ data class VkPhotoData(
ownerId = ownerId, ownerId = ownerId,
hasTags = hasTags == true, hasTags = hasTags == true,
accessKey = accessKey, accessKey = accessKey,
sizes = sizes, sizes = sizes.map { size ->
PhotoSize(
height = size.height,
width = size.width,
type = size.type,
url = size.url
)
},
text = text, text = text,
userId = userId userId = userId
) )
@@ -23,7 +23,7 @@ data class VkVideoData(
@Json(name = "is_favorite") val isFavorite: Boolean?, @Json(name = "is_favorite") val isFavorite: Boolean?,
@Json(name = "image") val image: List<Image>?, @Json(name = "image") val image: List<Image>?,
@Json(name = "first_frame") val firstFrame: List<FirstFrame>?, @Json(name = "first_frame") val firstFrame: List<FirstFrame>?,
@Json(name = "files") val files: File? @Json(name = "files") val files: File?,
) : VkAttachmentData { ) : VkAttachmentData {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -73,6 +73,7 @@ data class VkVideoData(
accessKey = accessKey, accessKey = accessKey,
title = title, title = title,
views = views, views = views,
duration = duration duration = duration,
isShortVideo = type == "short_video"
) )
} }
@@ -9,10 +9,10 @@ data class VkWallReplyData(
val from_id: Long, val from_id: Long,
val date: Int, val date: Int,
val text: String, val text: String,
val post_id: Long, val post_id: Long?,
val owner_id: Long, val owner_id: Long?,
val parents_stack: List<Int>, val parents_stack: List<Int>?,
val likes: Likes, val likes: Likes?,
val reply_to_user: Int?, val reply_to_user: Int?,
val reply_to_comment: Int? val reply_to_comment: Int?
) { ) {
@@ -3,6 +3,10 @@ package dev.meloda.fast.model.api.domain
enum class FormatDataType { enum class FormatDataType {
BOLD, ITALIC, UNDERLINE, URL; BOLD, ITALIC, UNDERLINE, URL;
override fun toString(): String {
return super.toString().lowercase()
}
companion object { companion object {
fun parse(value: String): FormatDataType? = fun parse(value: String): FormatDataType? =
entries.firstOrNull { it.name.lowercase() == value } entries.firstOrNull { it.name.lowercase() == value }
@@ -1,7 +1,7 @@
package dev.meloda.fast.model.api.domain package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.PhotoSize
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.data.VkPhotoData
import java.util.Stack import java.util.Stack
@@ -13,7 +13,7 @@ data class VkPhotoDomain(
val ownerId: Long, val ownerId: Long,
val hasTags: Boolean, val hasTags: Boolean,
val accessKey: String?, val accessKey: String?,
val sizes: List<VkPhotoData.Size>, val sizes: List<PhotoSize>,
val text: String?, val text: String?,
val userId: Long? val userId: Long?
) : VkAttachment { ) : VkAttachment {
@@ -35,11 +35,15 @@ data class VkPhotoDomain(
sizesChars.push(SIZE_TYPE_2560_2048) sizesChars.push(SIZE_TYPE_2560_2048)
} }
fun getMaxSize(): VkPhotoData.Size? { fun getMaxSize(): PhotoSize? {
return getSizeOrSmaller(sizesChars.peek()) return getSizeOrSmaller(sizesChars.peek())
} }
fun getSizeOrNull(type: Char): VkPhotoData.Size? { fun getDefault(): PhotoSize? {
return getSizeOrSmaller(SIZE_TYPE_1080_1024)
}
fun getSizeOrNull(type: Char): PhotoSize? {
for (size in sizes) { for (size in sizes) {
if (size.type == type.toString()) return size if (size.type == type.toString()) return size
} }
@@ -47,7 +51,7 @@ data class VkPhotoDomain(
return null return null
} }
fun getSizeOrSmaller(type: Char): VkPhotoData.Size? { fun getSizeOrSmaller(type: Char): PhotoSize? {
val photoStack = sizesChars.clone() as Stack<*> val photoStack = sizesChars.clone() as Stack<*>
val sizeIndex = photoStack.search(type) val sizeIndex = photoStack.search(type)
@@ -13,7 +13,8 @@ data class VkVideoDomain(
val accessKey: String?, val accessKey: String?,
val title: String, val title: String,
val views: Int, val views: Int,
val duration: Int val duration: Int,
val isShortVideo: Boolean
) : VkAttachment { ) : VkAttachment {
override val type: AttachmentType = AttachmentType.VIDEO override val type: AttachmentType = AttachmentType.VIDEO
@@ -22,6 +23,10 @@ data class VkVideoDomain(
return images.find { it.width == width } return images.find { it.width == width }
} }
fun getDefault(): VideoImage? {
return imageForWidthAtLeast(720)
}
fun imageForWidthAtLeast(width: Int): VideoImage? { fun imageForWidthAtLeast(width: Int): VideoImage? {
var certainImages = images.sortedByDescending { it.width } var certainImages = images.sortedByDescending { it.width }
var containsVertical = false var containsVertical = false
@@ -36,9 +41,11 @@ data class VkVideoDomain(
certainImages = certainImages.filter { it.shapeKind == ShapeKind.Vertical } certainImages = certainImages.filter { it.shapeKind == ShapeKind.Vertical }
} }
certainImages = certainImages.filter { it.width >= width } val filteredCertainImages = certainImages.filter { it.width >= width }
return certainImages.firstOrNull() return filteredCertainImages
.ifEmpty { certainImages }
.firstOrNull()
} }
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -2,6 +2,7 @@ package dev.meloda.fast.model.api.requests
import dev.meloda.fast.model.api.asInt import dev.meloda.fast.model.api.asInt
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesGetHistoryRequest( data class MessagesGetHistoryRequest(
val count: Int? = null, val count: Int? = null,
@@ -38,7 +39,8 @@ data class MessagesSendRequest(
val disableMentions: Boolean? = null, val disableMentions: Boolean? = null,
val doNotParseLinks: Boolean? = null, val doNotParseLinks: Boolean? = null,
val silent: Boolean? = null, val silent: Boolean? = null,
val attachments: List<VkAttachment>? = null val attachments: List<VkAttachment>? = null,
val formatData: VkMessage.FormatData? = null
) { ) {
val map: Map<String, String> val map: Map<String, String>
@@ -54,6 +56,13 @@ data class MessagesSendRequest(
disableMentions?.let { this["disable_mentions"] = it.asInt().toString() } disableMentions?.let { this["disable_mentions"] = it.asInt().toString() }
doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() } doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() }
silent?.let { this["silent"] = it.toString() } silent?.let { this["silent"] = it.toString() }
formatData?.let {
this["format_data"] = "{\"version\":\"${formatData.version}\",\"items\":[" +
formatData.items.joinToString(separator = ", ") { item ->
"{\"type\":\"${item.type}\",\"offset\":${item.offset},\"length\":${item.length}}"
} +
"]}"
}
// TODO: 05/05/2024, Danil Nikolaev: add attachments // TODO: 05/05/2024, Danil Nikolaev: add attachments
// attachments?.let { // attachments?.let {
@@ -3,7 +3,7 @@ package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@Immutable @Immutable
class ImmutableList<T>(val values: List<T>) : Iterable<T> { class ImmutableList<T>(val values: List<T>) : Collection<T> {
constructor(size: Int, init: (index: Int) -> T) : this(MutableList(size, init)) constructor(size: Int, init: (index: Int) -> T) : this(MutableList(size, init))
@@ -25,29 +25,17 @@ class ImmutableList<T>(val values: List<T>) : Iterable<T> {
return values.mapIndexed(transform).toImmutableList() return values.mapIndexed(transform).toImmutableList()
} }
fun singleOrNull(): T? { override fun isEmpty(): Boolean = values.isEmpty()
return if (values.size == 1) this[0] else null
override val size: Int get() = values.size
override fun containsAll(elements: Collection<T>): Boolean {
return values.containsAll(elements)
} }
fun isEmpty(): Boolean = values.isEmpty() override fun contains(element: T): Boolean {
return values.contains(element)
fun isNotEmpty(): Boolean = !isEmpty()
inline fun singleOrNull(predicate: (T) -> Boolean): T? {
var single: T? = null
var found = false
for (element in this) {
if (predicate(element)) {
if (found) return null
single = element
found = true
} }
}
if (!found) return null
return single
}
val size: Int get() = values.size
companion object { companion object {
fun <T> copyOf(collection: Collection<T>): ImmutableList<T> = fun <T> copyOf(collection: Collection<T>): ImmutableList<T> =
@@ -67,3 +55,7 @@ class ImmutableList<T>(val values: List<T>) : Iterable<T> {
} }
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList()) fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
@@ -107,6 +107,7 @@
<string name="message_attachments_files_few">%1$d файла</string> <string name="message_attachments_files_few">%1$d файла</string>
<string name="message_attachments_files_many">%1$d файлов</string> <string name="message_attachments_files_many">%1$d файлов</string>
<string name="message_attachments_files_other">%1$d файлов</string> <string name="message_attachments_files_other">%1$d файлов</string>
<string name="message_attachments_clip">Клип</string>
<string name="message_attachments_audio_message">Голосовое сообщение</string> <string name="message_attachments_audio_message">Голосовое сообщение</string>
<string name="message_attachments_link">Ссылка</string> <string name="message_attachments_link">Ссылка</string>
<string name="message_attachments_mini_app">Мини-приложение</string> <string name="message_attachments_mini_app">Мини-приложение</string>
@@ -263,4 +264,9 @@
<string name="conversation_context_action_archive">В архив</string> <string name="conversation_context_action_archive">В архив</string>
<string name="confirm_archive_conversation">Архивировать чат?</string> <string name="confirm_archive_conversation">Архивировать чат?</string>
<string name="action_archive">В архив</string> <string name="action_archive">В архив</string>
<string name="autofill">Автозаполнение</string>
<string name="bold">Жирный</string>
<string name="italic">Курсив</string>
<string name="underline">Подчёркнутый</string>
<string name="link">Ссылка</string>
</resources> </resources>
+7
View File
@@ -83,6 +83,7 @@
<string name="message_attachments_files_many">%1$d files</string> <string name="message_attachments_files_many">%1$d files</string>
<string name="message_attachments_files_other">%1$d files</string> <string name="message_attachments_files_other">%1$d files</string>
<string name="message_attachments_clip">Clip</string>
<string name="message_attachments_audio_message">Voice message</string> <string name="message_attachments_audio_message">Voice message</string>
<string name="message_attachments_link">Link</string> <string name="message_attachments_link">Link</string>
<string name="message_attachments_mini_app">Mini App</string> <string name="message_attachments_mini_app">Mini App</string>
@@ -338,4 +339,10 @@
<string name="unspam_message_title">Unmark as spam</string> <string name="unspam_message_title">Unmark as spam</string>
<string name="unspam_message_text">Are you sure you want to unmark this message as spam?</string> <string name="unspam_message_text">Are you sure you want to unmark this message as spam?</string>
<string name="copied_to_clipboard">Copied to clipboard</string> <string name="copied_to_clipboard">Copied to clipboard</string>
<string name="autofill">Autofill</string>
<string name="bold">Bold</string>
<string name="italic">Italic</string>
<string name="underline">Underline</string>
<string name="link">Link</string>
</resources> </resources>
+1 -8
View File
@@ -17,7 +17,7 @@ plugins {
androidComponents { androidComponents {
onVariants { variant -> onVariants { variant ->
variant.buildConfigFields.apply { variant.buildConfigFields?.apply {
put( put(
"sdkPackage", "sdkPackage",
BuildConfigField( BuildConfigField(
@@ -46,13 +46,6 @@ androidComponents {
} }
} }
// TODO: 29-Mar-25, Danil Nikolaev: remove when autofill changes will be in release
configurations.all {
resolutionStrategy {
force(libs.compose.ui)
}
}
android { android {
namespace = "dev.meloda.fast.auth" namespace = "dev.meloda.fast.auth"
@@ -42,13 +42,13 @@ import androidx.compose.ui.platform.testTag
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.semantics.contentType import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.password
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl import dev.meloda.fast.auth.login.LoginViewModelImpl
@@ -57,14 +57,12 @@ import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.LocalSizeConfig import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.util.handleEnterKey import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@Composable @Composable
@@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -36,7 +37,7 @@ fun Logo(modifier: Modifier = Modifier) {
val size = LocalSizeConfig.current val size = LocalSizeConfig.current
val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp) val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp)
val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 40) val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 38)
val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp) val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
val userSettings: UserSettings = koinInject() val userSettings: UserSettings = koinInject()
@@ -78,7 +79,8 @@ fun Logo(modifier: Modifier = Modifier) {
Text( Text(
text = stringResource(id = R.string.fast_messenger), text = stringResource(id = R.string.fast_messenger),
style = MaterialTheme.typography.displayMedium.copy(fontSize = appNameFontSize.sp), style = MaterialTheme.typography.displayMedium.copy(fontSize = appNameFontSize.sp),
color = MaterialTheme.colorScheme.onBackground color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
) )
} }
} }
@@ -1,67 +0,0 @@
package dev.meloda.fast.chatmaterials.presentation
import androidx.compose.foundation.clickable
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.compose.AsyncImage
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
@Composable
fun ChatMaterialItem(
item: UiChatMaterial,
onClick: () -> Unit
) {
when (item) {
is UiChatMaterial.Photo -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable(onClick = onClick)
)
}
is UiChatMaterial.Video -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
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 -> {}
}
}
@@ -63,6 +63,7 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.navigation.Conversations import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
@@ -116,7 +117,7 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset initialFirstVisibleItemScrollOffset = screenState.scrollOffset
) )
val currentTabReselected = LocalReselectedTab.current[Conversations] ?: false val currentTabReselected = LocalReselectedTab.current[ConversationsGraph] ?: false
LaunchedEffect(currentTabReselected) { LaunchedEffect(currentTabReselected) {
if (currentTabReselected) { if (currentTabReselected) {
if (screenState.isArchive) { if (screenState.isArchive) {
@@ -22,6 +22,7 @@ import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkVideoDomain
import dev.meloda.fast.ui.model.api.ActionState import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.ConversationOption import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation import dev.meloda.fast.ui.model.api.UiConversation
@@ -728,6 +729,12 @@ private fun getAttachmentUiText(
attachment: VkAttachment, attachment: VkAttachment,
size: Int = 1, size: Int = 1,
): UiText { ): UiText {
if (attachment.type == AttachmentType.VIDEO &&
(attachment as? VkVideoDomain)?.isShortVideo == true
) {
return UiText.Resource(UiR.string.message_attachments_clip)
}
if (attachment.type.isMultiple()) { if (attachment.type.isMultiple()) {
return when (attachment.type) { return when (attachment.type) {
AttachmentType.PHOTO -> UiR.plurals.attachment_photos AttachmentType.PHOTO -> UiR.plurals.attachment_photos
@@ -47,7 +47,6 @@ fun FriendItem(
val friendAvatar = friend.avatar?.extractUrl() val friendAvatar = friend.avatar?.extractUrl()
Box(modifier = Modifier.size(56.dp)) { Box(modifier = Modifier.size(56.dp)) {
if (friendAvatar == null) {
Image( Image(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -56,7 +55,6 @@ fun FriendItem(
contentDescription = "Avatar", contentDescription = "Avatar",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
) )
} else {
AsyncImage( AsyncImage(
model = friendAvatar, model = friendAvatar,
contentDescription = null, contentDescription = null,
@@ -70,7 +68,6 @@ fun FriendItem(
}, },
placeholder = painterResource(id = R.drawable.ic_account_circle_cut) placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
) )
}
if (friend.onlineStatus.isOnline()) { if (friend.onlineStatus.isOnline()) {
Box( Box(
@@ -7,9 +7,15 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -19,6 +25,7 @@ import com.conena.nanokt.text.isNotEmptyOrBlank
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.orDots import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.extensions.removeIfCompat
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
@@ -43,6 +50,7 @@ import dev.meloda.fast.messageshistory.util.extractAvatar
import dev.meloda.fast.messageshistory.util.extractTitle import dev.meloda.fast.messageshistory.util.extractTitle
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
@@ -98,6 +106,11 @@ interface MessagesHistoryViewModel {
fun onUnpinMessageClicked() fun onUnpinMessageClicked()
fun onDeleteSelectedMessagesClicked() fun onDeleteSelectedMessagesClicked()
fun onBoldClicked()
fun onItalicClicked()
fun onUnderlineClicked()
fun onLinkClicked()
} }
class MessagesHistoryViewModelImpl( class MessagesHistoryViewModelImpl(
@@ -346,8 +359,7 @@ class MessagesHistoryViewModelImpl(
else ActionMode.Send else ActionMode.Send
) )
} }
updateStyles()
screenState.setValue { old -> old.copy(message = newText) }
} }
override fun onEmojiButtonLongClicked() { override fun onEmojiButtonLongClicked() {
@@ -447,6 +459,118 @@ class MessagesHistoryViewModelImpl(
} }
} }
private var formatData = VkMessage.FormatData("1", emptyList())
private fun updateStyles() {
val annotations =
mutableListOf<AnnotatedString.Range<out AnnotatedString.Annotation>>()
formatData.items.forEachIndexed { index, item ->
val spanStyle = when (item.type) {
FormatDataType.BOLD -> {
SpanStyle(fontWeight = FontWeight.SemiBold)
}
FormatDataType.ITALIC -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
FormatDataType.UNDERLINE -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
FormatDataType.URL -> null
}
spanStyle?.let {
annotations += AnnotatedString.Range(
item = spanStyle,
start = item.offset,
end = item.offset + item.length
)
}
}
val newText = AnnotatedString(
text = screenState.value.message.text,
annotations = annotations
)
screenState.setValue { old ->
old.copy(message = old.message.copy(annotatedString = newText))
}
}
override fun onBoldClicked() {
val selectionRange = screenState.value.message.selection
val newItems = formatData.items.toMutableList()
val wasRemoved = newItems.removeIfCompat {
it.type == FormatDataType.BOLD &&
it.offset == selectionRange.start &&
it.offset + it.length == selectionRange.end
}
if (!wasRemoved) {
newItems += VkMessage.FormatData.Item(
offset = selectionRange.start,
length = selectionRange.end - selectionRange.start,
type = FormatDataType.BOLD,
url = null
)
}
formatData = formatData.copy(items = newItems)
updateStyles()
}
override fun onItalicClicked() {
val selectionRange = screenState.value.message.selection
val newItems = formatData.items.toMutableList()
val wasRemoved = newItems.removeIfCompat {
it.type == FormatDataType.ITALIC &&
it.offset == selectionRange.start &&
it.offset + it.length == selectionRange.end
}
if (!wasRemoved) {
newItems += VkMessage.FormatData.Item(
offset = selectionRange.start,
length = selectionRange.end - selectionRange.start,
type = FormatDataType.ITALIC,
url = null
)
}
formatData = formatData.copy(items = newItems)
updateStyles()
}
override fun onUnderlineClicked() {
val selectionRange = screenState.value.message.selection
val newItems = formatData.items.toMutableList()
val wasRemoved = newItems.removeIfCompat {
it.type == FormatDataType.UNDERLINE &&
it.offset == selectionRange.start &&
it.offset + it.length == selectionRange.end
}
if (!wasRemoved) {
newItems += VkMessage.FormatData.Item(
offset = selectionRange.start,
length = selectionRange.end - selectionRange.start,
type = FormatDataType.UNDERLINE,
url = null
)
}
formatData = formatData.copy(items = newItems)
updateStyles()
}
override fun onLinkClicked() {
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
@@ -800,8 +924,9 @@ class MessagesHistoryViewModelImpl(
pinnedAt = null, pinnedAt = null,
// TODO: 04-Apr-25, Danil Nikolaev: implement // TODO: 04-Apr-25, Danil Nikolaev: implement
formatData = null, formatData = formatData,
) )
formatData = formatData.copy(items = emptyList())
sendingMessages += newMessage sendingMessages += newMessage
messages.setValue { old -> listOf(newMessage).plus(old) } messages.setValue { old -> listOf(newMessage).plus(old) }
syncUiMessages() syncUiMessages()
@@ -818,7 +943,8 @@ class MessagesHistoryViewModelImpl(
randomId = newMessage.randomId, randomId = newMessage.randomId,
message = newMessage.text, message = newMessage.text,
replyTo = null, replyTo = null,
attachments = null attachments = null,
formatData = newMessage.formatData
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
any = { sendingMessages.remove(newMessage) }, any = { sendingMessages.remove(newMessage) },
@@ -2,15 +2,16 @@ package dev.meloda.fast.messageshistory.model
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
sealed class UiItem( sealed class UiItem(
open val id: Long, open val id: Long,
val cmId: Long open val cmId: Long
) { ) {
data class Message( data class Message(
override val id: Long, override val id: Long,
val conversationMessageId: Long, override val cmId: Long,
val text: AnnotatedString?, val text: AnnotatedString?,
val isOut: Boolean, val isOut: Boolean,
val fromId: Long, val fromId: Long,
@@ -27,13 +28,14 @@ sealed class UiItem(
val sendingStatus: SendingStatus, val sendingStatus: SendingStatus,
val isSelected: Boolean, val isSelected: Boolean,
val isPinned: Boolean, val isPinned: Boolean,
val isImportant: Boolean val isImportant: Boolean,
) : UiItem(id, conversationMessageId) val attachments: List<VkAttachment>?
) : UiItem(id, cmId)
data class ActionMessage( data class ActionMessage(
override val id: Long, override val id: Long,
val conversationMessageId: Long, override val cmId: Long,
val text: AnnotatedString, val text: AnnotatedString,
val actionCmId: Long? val actionCmId: Long?
) : UiItem(id, conversationMessageId) ) : UiItem(id, cmId)
} }
@@ -62,7 +62,7 @@ fun ActionMessageItemPreview() {
append("You pinned message \"wow hello there\"") append("You pinned message \"wow hello there\"")
}, },
actionCmId = null, actionCmId = null,
conversationMessageId = 2135 cmId = 2135
) )
) )
} }
@@ -0,0 +1,103 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig
@Composable
fun BoxScope.DateStatus(
modifier: Modifier = Modifier,
dateContainerWidth: Dp,
date: String,
sendingStatus: SendingStatus,
isImportant: Boolean,
isPinned: Boolean,
isEdited: Boolean,
isOut: Boolean,
isRead: Boolean
) {
val theme = LocalThemeConfig.current
Row(
modifier = modifier.then(
if (theme.enableAnimations) Modifier.animateContentSize()
else Modifier
),
verticalAlignment = Alignment.CenterVertically
) {
if (isImportant) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
painter = painterResource(R.drawable.round_star_24),
contentDescription = null,
modifier = Modifier.size(14.dp)
)
}
if (isPinned) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
painter = painterResource(R.drawable.ic_round_push_pin_24),
contentDescription = null,
modifier = Modifier
.size(14.dp)
.rotate(45f)
)
}
if (isEdited) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Rounded.Create,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = date,
style = MaterialTheme.typography.labelSmall
)
Spacer(modifier = Modifier.width(4.dp))
if (isOut) {
Icon(
modifier = Modifier.size(14.dp),
painter = painterResource(
when (sendingStatus) {
SendingStatus.SENDING -> R.drawable.round_access_time_24
SendingStatus.SENT -> {
if (isRead) R.drawable.round_done_all_24
else R.drawable.ic_round_done_24
}
SendingStatus.FAILED -> R.drawable.round_error_outline_24
}
),
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
else LocalContentColor.current,
contentDescription = null
)
}
}
}
@@ -27,12 +27,16 @@ import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.imageLoader import coil.imageLoader
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable @Composable
fun IncomingMessageBubble( fun IncomingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -87,12 +91,15 @@ fun IncomingMessageBubble(
text = message.text, text = message.text,
isOut = false, isOut = false,
date = message.date, date = message.date,
edited = message.isEdited, isEdited = message.isEdited,
isRead = message.isRead, isRead = message.isRead,
sendingStatus = message.sendingStatus, sendingStatus = message.sendingStatus,
pinned = message.isPinned, isPinned = message.isPinned,
important = message.isImportant, isImportant = message.isImportant,
isSelected = message.isSelected isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(),
onClick = onClick,
onLongClick = onLongClick
) )
} }
} }
@@ -4,51 +4,51 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
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.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.presentation.attachments.Attachments
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Composable @Composable
fun MessageBubble( fun MessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: AnnotatedString?, text: AnnotatedString?,
isOut: Boolean, isOut: Boolean,
date: String?, date: String,
edited: Boolean, isEdited: Boolean,
isRead: Boolean, isRead: Boolean,
sendingStatus: SendingStatus, sendingStatus: SendingStatus,
pinned: Boolean, isPinned: Boolean,
important: Boolean, isImportant: Boolean,
isSelected: Boolean isSelected: Boolean,
attachments: ImmutableList<VkAttachment>?,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) { ) {
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) { val backgroundColor = if (!isOut) {
@@ -63,24 +63,12 @@ fun MessageBubble(
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
} }
CompositionLocalProvider(LocalContentColor provides contentColor) { val minDateContainerWidth by remember(isEdited, isOut, isPinned, isImportant) {
Box(
modifier = modifier
.widthIn(min = 56.dp)
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(
horizontal = 8.dp,
vertical = 6.dp
)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
val minDateContainerWidth by remember(edited, isOut, pinned, important) {
derivedStateOf { derivedStateOf {
val mainPart = if (edited) 50.dp else 30.dp val mainPart = if (isEdited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp val readIndicatorPart = if (isOut) 14.dp else 0.dp
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp val pinnedIndicatorPart = if (isPinned) 14.dp else 0.dp
val importantIndicatorPart = if (important) 14.dp else 0.dp val importantIndicatorPart = if (isImportant) 14.dp else 0.dp
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
} }
@@ -91,123 +79,146 @@ fun MessageBubble(
label = "dateContainerWidth" label = "dateContainerWidth"
) )
if (text != null) { val shouldShowBubble by remember(text) {
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) { derivedStateOf { text != null }
{
Text(
text = kotlin.run {
val builder = AnnotatedString.Builder(text)
text.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color =
if (isOut) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.primary
} }
var bubbleContainerWidth by remember {
mutableIntStateOf(0)
}
var attachmentsContainerWidth by remember {
mutableIntStateOf(0)
}
val shouldFill by remember(bubbleContainerWidth, attachmentsContainerWidth) {
derivedStateOf {
attachmentsContainerWidth >= bubbleContainerWidth
}
}
CompositionLocalProvider(LocalContentColor provides contentColor) {
Column {
if (shouldShowBubble) {
Box(
modifier = modifier
.onGloballyPositioned {
bubbleContainerWidth = it.size.width
}
.widthIn(min = if (shouldFill) attachmentsContainerWidth.dp else 56.dp)
.clip(
if (attachments == null) RoundedCornerShape(24.dp)
else RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
) )
} else {
spanStyleRange.item
}
builder.addStyle(
style = updatedSpanStyle,
start = spanStyleRange.start,
end = spanStyleRange.end
) )
} .background(backgroundColor)
.padding(
text.paragraphStyles.forEach { style -> horizontal = 8.dp,
builder.addStyle( vertical = 6.dp
style = style.item,
start = style.start,
end = style.end
) )
} .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
builder.toAnnotatedString() MessageTextContainer(
},
modifier = Modifier modifier = Modifier
.padding(2.dp) .padding(2.dp)
.align(Alignment.Center)
.padding(end = 4.dp) .padding(end = 4.dp)
.padding(end = dateContainerWidth) .padding(end = dateContainerWidth)
.padding(end = 4.dp) .padding(end = 4.dp)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier) .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
text = text,
isOut = isOut,
isSelected = isSelected,
)
if (attachments == null) {
DateStatus(
modifier = Modifier
.padding(top = 3.dp)
.align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth),
dateContainerWidth = dateContainerWidth,
date = date,
sendingStatus = sendingStatus,
isImportant = isImportant,
isPinned = isPinned,
isEdited = isEdited,
isOut = isOut,
isRead = isRead
) )
} }
} }
if (isSelected) {
SelectionContainer {
textLambda.invoke()
}
} else {
textLambda.invoke()
}
} }
Row( if (attachments != null) {
Box(
modifier = Modifier
.onGloballyPositioned {
attachmentsContainerWidth = it.size.width
}
.clip(
if (!shouldShowBubble) RoundedCornerShape(24.dp)
else RoundedCornerShape(
bottomEnd = 24.dp,
bottomStart = 24.dp,
topStart = 0.dp,
topEnd = 0.dp
)
)
.background(backgroundColor)
) {
Attachments(
modifier = Modifier,
attachments = attachments,
onClick = onClick,
onLongClick = onLongClick
)
val dateStatusBackground = if (theme.darkMode) Color.Black.copy(alpha = 0.5f)
else Color.White.copy(alpha = 0.5f)
CompositionLocalProvider(LocalContentColor provides contentColor) {
DateStatus(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth) .padding(bottom = 6.dp, end = 6.dp)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), .widthIn(min = 42.dp)
) { .clip(RoundedCornerShape(24.dp))
if (important) { .background(dateStatusBackground)
Icon( .padding(4.dp),
painter = painterResource(UiR.drawable.round_star_24), dateContainerWidth = dateContainerWidth,
contentDescription = null, date = date,
modifier = Modifier.size(14.dp) sendingStatus = sendingStatus,
isImportant = isImportant,
isPinned = isPinned,
isEdited = isEdited,
isOut = isOut,
isRead = isRead
) )
Spacer(modifier = Modifier.width(4.dp))
} }
if (pinned) {
Icon(
painter = painterResource(UiR.drawable.ic_round_push_pin_24),
contentDescription = null,
modifier = Modifier
.size(14.dp)
.rotate(45f)
)
Spacer(modifier = Modifier.width(4.dp))
} }
if (edited) { }
Icon( }
imageVector = Icons.Rounded.Create, }
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
} }
Text( @Preview
text = date.orEmpty(), @Composable
style = MaterialTheme.typography.labelSmall private fun Bubble() {
) MessageBubble(
Spacer(modifier = Modifier.width(4.dp)) modifier = Modifier,
text = AnnotatedString("Some cool text"),
if (isOut) { isOut = true,
Icon( date = "19:01",
modifier = Modifier.size(14.dp), isEdited = true,
painter = painterResource( isRead = true,
when (sendingStatus) { sendingStatus = SendingStatus.SENT,
SendingStatus.SENDING -> UiR.drawable.round_access_time_24 isPinned = true,
SendingStatus.SENT -> { isImportant = true,
if (isRead) UiR.drawable.round_done_all_24 isSelected = false,
else UiR.drawable.ic_round_done_24 attachments = emptyImmutableList()
}
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
}
),
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
else LocalContentColor.current,
contentDescription = null
) )
} }
}
}
}
}
@@ -0,0 +1,95 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun MessageTextContainer(
modifier: Modifier = Modifier,
text: AnnotatedString?,
isOut: Boolean,
isSelected: Boolean,
) {
if (text != null) {
if (isSelected) {
SelectionContainer {
MessageText(
modifier = modifier,
text = text,
isOut = isOut,
)
}
} else {
MessageText(
modifier = modifier,
text = text,
isOut = isOut,
)
}
}
}
@Composable
fun MessageText(
modifier: Modifier = Modifier,
text: AnnotatedString,
isOut: Boolean,
) {
val replacedColor = if (isOut) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.primary
}
val newText = remember(text) {
val builder = AnnotatedString.Builder(text)
text.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = replacedColor)
} else {
spanStyleRange.item
}
builder.addStyle(
style = updatedSpanStyle,
start = spanStyleRange.start,
end = spanStyleRange.end
)
}
text.paragraphStyles.forEach { style ->
builder.addStyle(
style = style.item,
start = style.start,
end = style.end
)
}
builder.toAnnotatedString()
}
Text(
text = newText,
modifier = modifier,
)
}
@Preview
@Composable
private fun MessageTextPreview() {
MessageTextContainer(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
isOut = true,
isSelected = false
)
}
@@ -70,7 +70,10 @@ fun MessagesHistoryRoute(
onMessageLongClicked = viewModel::onMessageLongClicked, onMessageLongClicked = viewModel::onMessageLongClicked,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked, onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked, onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
onBoldRequested = viewModel::onBoldClicked,
onItalicRequested = viewModel::onItalicClicked,
onUnderlineRequested = viewModel::onUnderlineClicked
) )
HandleDialogs( HandleDialogs(
@@ -1,7 +1,13 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import android.os.Build
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
@@ -56,6 +62,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -67,11 +74,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
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.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -133,7 +144,10 @@ fun MessagesHistoryScreen(
onMessageLongClicked: (Long) -> Unit = {}, onMessageLongClicked: (Long) -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {}, onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}, onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {} onDeleteSelectedButtonClicked: () -> Unit = {},
onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {},
onUnderlineRequested: () -> Unit = {},
) { ) {
val view = LocalView.current val view = LocalView.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -289,7 +303,7 @@ fun MessagesHistoryScreen(
Text( Text(
text = when { text = when {
screenState.isLoading -> stringResource(id = UiR.string.title_loading) screenState.isLoading -> stringResource(id = UiR.string.title_loading)
selectedMessages.size > 0 -> "(${selectedMessages.size})" selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
else -> screenState.title else -> screenState.title
}, },
maxLines = 1, maxLines = 1,
@@ -305,15 +319,18 @@ fun MessagesHistoryScreen(
else onClose() else onClose()
} }
) { ) {
Crossfade(targetState = selectedMessages.isEmpty()) { state ->
Icon( Icon(
imageVector = if (selectedMessages.isEmpty()) { imageVector = if (state) {
Icons.AutoMirrored.Rounded.ArrowBack Icons.AutoMirrored.Rounded.ArrowBack
} else { } else {
Icons.Rounded.Close Icons.Rounded.Close
}, },
contentDescription = "Back button" contentDescription = if (state) "Close button"
else "Back button"
) )
} }
}
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
actions = { actions = {
@@ -557,6 +574,18 @@ fun MessagesHistoryScreen(
} }
} }
val view = LocalView.current
val textToolbar = remember {
CustomTextToolbar(
view = view,
onBoldRequested = onBoldRequested,
onItalicRequested = onItalicRequested,
onUnderlineRequested = onUnderlineRequested,
onLinkRequested = {}
)
}
CompositionLocalProvider(LocalTextToolbar provides textToolbar) {
TextField( TextField(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
value = screenState.message, value = screenState.message,
@@ -575,6 +604,7 @@ fun MessagesHistoryScreen(
) )
} }
) )
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val attachmentRotation = remember { Animatable(0f) } val attachmentRotation = remember { Animatable(0f) }
@@ -687,3 +717,242 @@ fun MessagesHistoryScreen(
} }
} }
} }
class CustomTextToolbar(
private val view: View,
private var onBoldRequested: (() -> Unit)? = null,
private var onItalicRequested: (() -> Unit)? = null,
private var onUnderlineRequested: (() -> Unit)? = null,
private var onLinkRequested: (() -> Unit)? = null
) : TextToolbar {
private var actionMode: android.view.ActionMode? = null
private val textActionModeCallback: TextActionModeCallback =
TextActionModeCallback(onActionModeDestroy = { actionMode = null })
override var status: TextToolbarStatus = TextToolbarStatus.Hidden
private set
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
onAutofillRequested: (() -> Unit)?
) {
textActionModeCallback.rect = rect
textActionModeCallback.onCopyRequested = onCopyRequested
textActionModeCallback.onCutRequested = onCutRequested
textActionModeCallback.onPasteRequested = onPasteRequested
textActionModeCallback.onSelectAllRequested = onSelectAllRequested
textActionModeCallback.onAutofillRequested = onAutofillRequested
textActionModeCallback.onBoldRequested = onBoldRequested
textActionModeCallback.onItalicRequested = onItalicRequested
textActionModeCallback.onUnderlineRequested = onUnderlineRequested
textActionModeCallback.onLinkRequested = onLinkRequested
if (actionMode == null) {
status = TextToolbarStatus.Shown
actionMode =
TextToolbarHelperMethods.startActionMode(
view,
FloatingTextActionModeCallback(textActionModeCallback),
android.view.ActionMode.TYPE_FLOATING
)
} else {
actionMode?.invalidate()
}
}
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?
) {
showMenu(
rect = rect,
onCopyRequested = onCopyRequested,
onPasteRequested = onPasteRequested,
onCutRequested = onCutRequested,
onSelectAllRequested = onSelectAllRequested,
onAutofillRequested = null
)
}
override fun hide() {
status = TextToolbarStatus.Hidden
actionMode?.finish()
actionMode = null
}
}
/**
* This class is here to ensure that the classes that use this API will get verified and can be AOT
* compiled. It is expected that this class will soft-fail verification, but the classes which use
* this method will pass.
*/
internal object TextToolbarHelperMethods {
fun startActionMode(
view: View,
actionModeCallback: android.view.ActionMode.Callback,
type: Int
): android.view.ActionMode? {
return view.startActionMode(actionModeCallback, type)
}
fun invalidateContentRect(actionMode: android.view.ActionMode) {
actionMode.invalidateContentRect()
}
}
class FloatingTextActionModeCallback(private val callback: TextActionModeCallback) :
android.view.ActionMode.Callback2() {
override fun onActionItemClicked(mode: android.view.ActionMode?, item: MenuItem?): Boolean {
return callback.onActionItemClicked(mode, item)
}
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
return callback.onCreateActionMode(mode, menu)
}
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
return callback.onPrepareActionMode(mode, menu)
}
override fun onDestroyActionMode(mode: android.view.ActionMode?) {
callback.onDestroyActionMode()
}
override fun onGetContentRect(
mode: android.view.ActionMode?,
view: View?,
outRect: android.graphics.Rect?
) {
val rect = callback.rect
outRect?.set(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt())
}
}
class TextActionModeCallback(
val onActionModeDestroy: (() -> Unit)? = null,
var rect: Rect = Rect.Zero,
var onCopyRequested: (() -> Unit)? = null,
var onPasteRequested: (() -> Unit)? = null,
var onCutRequested: (() -> Unit)? = null,
var onSelectAllRequested: (() -> Unit)? = null,
var onAutofillRequested: (() -> Unit)? = null,
var onBoldRequested: (() -> Unit)? = null,
var onItalicRequested: (() -> Unit)? = null,
var onUnderlineRequested: (() -> Unit)? = null,
var onLinkRequested: (() -> Unit)? = null
) {
fun onCreateActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
requireNotNull(menu) { "onCreateActionMode requires a non-null menu" }
requireNotNull(mode) { "onCreateActionMode requires a non-null mode" }
onCopyRequested?.let { addMenuItem(menu, MenuItemOption.Copy) }
onPasteRequested?.let { addMenuItem(menu, MenuItemOption.Paste) }
onCutRequested?.let { addMenuItem(menu, MenuItemOption.Cut) }
onSelectAllRequested?.let { addMenuItem(menu, MenuItemOption.SelectAll) }
if (onAutofillRequested != null && Build.VERSION.SDK_INT >= 26) {
addMenuItem(menu, MenuItemOption.Autofill)
}
onBoldRequested?.let { addMenuItem(menu, MenuItemOption.Bold) }
onItalicRequested?.let { addMenuItem(menu, MenuItemOption.Italic) }
onUnderlineRequested?.let { addMenuItem(menu, MenuItemOption.Underline) }
onLinkRequested?.let { addMenuItem(menu, MenuItemOption.Link) }
return true
}
// this method is called to populate new menu items when the actionMode was invalidated
fun onPrepareActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
if (mode == null || menu == null) return false
updateMenuItems(menu)
// should return true so that new menu items are populated
return true
}
fun onActionItemClicked(mode: android.view.ActionMode?, item: MenuItem?): Boolean {
when (item!!.itemId) {
MenuItemOption.Copy.ordinal -> onCopyRequested?.invoke()
MenuItemOption.Paste.ordinal -> onPasteRequested?.invoke()
MenuItemOption.Cut.ordinal -> onCutRequested?.invoke()
MenuItemOption.SelectAll.ordinal -> onSelectAllRequested?.invoke()
MenuItemOption.Autofill.ordinal -> onAutofillRequested?.invoke()
MenuItemOption.Bold.ordinal -> onBoldRequested?.invoke()
MenuItemOption.Italic.ordinal -> onItalicRequested?.invoke()
MenuItemOption.Underline.ordinal -> onUnderlineRequested?.invoke()
MenuItemOption.Link.ordinal -> onLinkRequested?.invoke()
else -> return false
}
mode?.finish()
return true
}
fun onDestroyActionMode() {
onActionModeDestroy?.invoke()
}
@VisibleForTesting
internal fun updateMenuItems(menu: Menu) {
addOrRemoveMenuItem(menu, MenuItemOption.Copy, onCopyRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Paste, onPasteRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Cut, onCutRequested)
addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Autofill, onAutofillRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Bold, onBoldRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Italic, onItalicRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Underline, onUnderlineRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Link, onLinkRequested)
}
private fun addMenuItem(menu: Menu, item: MenuItemOption) {
menu
.add(0, item.ordinal, item.order, item.titleResource)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
private fun addOrRemoveMenuItem(menu: Menu, item: MenuItemOption, callback: (() -> Unit)?) {
when {
callback != null && menu.findItem(item.ordinal) == null -> addMenuItem(menu, item)
callback == null && menu.findItem(item.ordinal) != null -> menu.removeItem(item.ordinal)
}
}
}
internal enum class MenuItemOption {
Copy,
Paste,
Cut,
SelectAll,
Autofill,
Bold,
Italic,
Underline,
Link;
val titleResource: Int
get() =
when (this) {
Copy -> android.R.string.copy
Paste -> android.R.string.paste
Cut -> android.R.string.cut
SelectAll -> android.R.string.selectAll
Autofill ->
if (Build.VERSION.SDK_INT <= 26) {
UiR.string.autofill
} else {
android.R.string.autofill
}
Bold -> UiR.string.bold
Italic -> UiR.string.italic
Underline -> UiR.string.underline
Link -> UiR.string.link
}
/** This item will be shown before all items that have order greater than this value. */
val order = ordinal
}
@@ -1,5 +1,8 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@@ -20,11 +23,13 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -32,8 +37,13 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import androidx.core.net.toUri
import dev.meloda.fast.model.api.domain.VkFileDomain
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -49,9 +59,63 @@ fun MessagesList(
onMessageClicked: (Long) -> Unit = {}, onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {} onMessageLongClicked: (Long) -> Unit = {}
) { ) {
val context = LocalContext.current
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val view = LocalView.current val view = LocalView.current
val isSelectedAtLeastOne by remember(uiMessages) {
derivedStateOf {
uiMessages.values.any { (it as? UiItem.Message)?.isSelected == true }
}
}
val onAttachmentClick = remember {
{ message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageClicked(message.id)
} else {
when (attachment) {
is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize()
maxSize?.let {
context.startActivity(
Intent(Intent.ACTION_VIEW, maxSize.url.toUri())
)
}
}
is VkFileDomain -> {
context.startActivity(
Intent(Intent.ACTION_VIEW, attachment.url.toUri())
)
}
is VkLinkDomain -> {
context.startActivity(
Intent(Intent.ACTION_VIEW, attachment.url.toUri())
)
}
}
}
}
}
val onAttachmentLongClick = remember {
{ message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageLongClicked(message.id)
uiMessages
} else {
when (attachment) {
is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize()
Log.d("MessagesList", "onPhotoLongClicked. Max size: ${maxSize?.url}")
}
}
}
}
}
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -141,7 +205,13 @@ fun MessagesList(
) )
else Modifier else Modifier
), ),
message = item message = item,
onClick = { attachment ->
onAttachmentClick(item, attachment)
},
onLongClick = { attachment ->
onAttachmentLongClick(item, attachment)
}
) )
} else { } else {
IncomingMessageBubble( IncomingMessageBubble(
@@ -155,7 +225,13 @@ fun MessagesList(
) )
else Modifier else Modifier
), ),
message = item message = item,
onClick = { attachment ->
onAttachmentClick(item, attachment)
},
onLongClick = { attachment ->
onAttachmentLongClick(item, attachment)
}
) )
} }
} }
@@ -10,14 +10,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable @Composable
fun OutgoingMessageBubble( fun OutgoingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -41,12 +44,15 @@ fun OutgoingMessageBubble(
text = message.text, text = message.text,
isOut = true, isOut = true,
date = message.date, date = message.date,
edited = message.isEdited, isEdited = message.isEdited,
isRead = message.isRead, isRead = message.isRead,
sendingStatus = message.sendingStatus, sendingStatus = message.sendingStatus,
pinned = message.isPinned, isPinned = message.isPinned,
important = message.isImportant, isImportant = message.isImportant,
isSelected = message.isSelected isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(),
onClick = onClick,
onLongClick = onLongClick
) )
} }
} }
@@ -0,0 +1,165 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAudioDomain
import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.model.api.domain.VkVideoDomain
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
private val previewTypes = listOf(
AttachmentType.PHOTO,
AttachmentType.VIDEO
)
@Composable
fun Attachments(
modifier: Modifier = Modifier,
attachments: ImmutableList<out VkAttachment>,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) {
Column(modifier = modifier) {
if (attachments.isEmpty()) return
val previewAttachments by remember(attachments) {
derivedStateOf {
attachments.values.filter { it.type in previewTypes }
}
}
val nonPreviewAttachments by remember(attachments) {
derivedStateOf {
attachments.values.filterNot { it.type in previewTypes }
.sortedBy { it.type.ordinal }
}
}
if (previewAttachments.isNotEmpty()) {
Previews(
modifier = Modifier,
photos = previewAttachments
.map(VkAttachment::asUiPhoto)
.toImmutableList(),
onClick = { index ->
onClick(previewAttachments[index])
},
onLongClick = { index ->
onLongClick(previewAttachments[index])
}
)
}
nonPreviewAttachments.forEach { attachment ->
when (attachment.type) {
AttachmentType.AUDIO -> {
Audio(
item = attachment as VkAudioDomain,
modifier = Modifier
)
}
AttachmentType.FILE -> {
File(
item = attachment as VkFileDomain,
modifier = Modifier,
onClick = { onClick(attachment) },
onLongClick = { onLongClick(attachment) }
)
}
AttachmentType.LINK -> {
Link(
item = attachment as VkLinkDomain,
modifier = Modifier,
onClick = { onClick(attachment) },
onLongClick = { onLongClick(attachment) }
)
}
else -> Unit
}
}
}
}
fun VkAttachment.asUiPhoto(): UiPreview {
return when (this) {
is VkPhotoDomain -> {
val size = this.getDefault()!!
UiPreview(
id = this.id,
url = size.url,
width = size.width,
height = size.height,
isVideo = false
)
}
is VkVideoDomain -> {
val size = this.getDefault() ?: VkVideoDomain.VideoImage(
width = 1280,
height = 720,
url = "",
withPadding = false
)
UiPreview(
id = this.id,
url = size.url,
width = size.width,
height = size.height,
isVideo = true
)
}
is VkFileDomain -> {
when {
this.preview?.video != null -> {
val video = this.preview?.video!!
UiPreview(
id = id,
url = video.src,
width = video.width,
height = video.height,
isVideo = true
)
}
this.preview?.photo != null -> {
val photoSize = this.preview?.photo?.sizes?.first()!!
UiPreview(
id = id,
url = photoSize.src,
width = photoSize.width,
height = photoSize.height,
isVideo = false
)
}
else -> error("Unsupported type: $this")
}
}
else -> error("Unsupported type: $this")
}
}
data class UiPreview(
val id: Long,
val url: String,
val width: Int,
val height: Int,
val isVideo: Boolean
)
@@ -0,0 +1,105 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.foundation.background
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.heightIn
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.meloda.fast.model.api.domain.VkAudioDomain
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import java.util.Locale
@Composable
fun Audio(
modifier: Modifier = Modifier,
item: VkAudioDomain
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(36.dp)
.padding(4.dp),
painter = painterResource(R.drawable.round_play_arrow_24),
contentDescription = null,
tint = contentColorFor(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = item.artist,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
// TODO: 11-Apr-25, Danil Nikolaev: extract to ui model
val formattedDuration by remember(item) {
derivedStateOf {
val duration = item.duration
val days = duration / (24 * 3600)
val hours = (duration % (24 * 3600)) / 3600
val minutes = (duration % 3600) / 60
val seconds = duration % 60
val args = mutableListOf<Int>()
if (days > 0) args.add(days)
if (hours > 0) args.add(hours)
args.add(minutes)
args.add(seconds)
val builder = StringBuilder()
if (days > 0) builder.append("%02d:")
if (hours > 0) builder.append("%02d:")
builder.append("%d:%02d")
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
}
}
Text(
text = formattedDuration,
style = MaterialTheme.typography.bodySmall
)
}
}
@@ -0,0 +1,142 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
@Composable
fun File(
modifier: Modifier = Modifier,
item: VkFileDomain,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 8.dp)
.clip(RoundedCornerShape(4.dp))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
verticalAlignment = Alignment.CenterVertically
) {
var errorLoading by remember {
mutableStateOf(false)
}
// TODO: 11-Apr-25, Danil Nikolaev: extract to ui model
val preview by remember(item) {
derivedStateOf {
when (val preview = item.preview) {
null -> null
else -> {
when {
preview.photo != null -> {
val size = preview.photo?.sizes?.maxByOrNull { it.width }
size?.src
}
preview.video != null -> {
val size = preview.video?.src
size
}
else -> null
}
}
}
}
}
val formattedSize by remember(item) {
derivedStateOf {
AndroidUtils.bytesToHumanReadableSize(item.size.toDouble())
}
}
if (preview != null && !errorLoading) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.size(width = 48.dp, height = 36.dp),
painter = rememberAsyncImagePainter(
model = preview,
imageLoader = LocalContext.current.imageLoader,
onState = {
errorLoading = it is AsyncImagePainter.State.Error
}
),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
Text(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp))
.size(width = 48.dp, height = 36.dp),
text = item.ext.uppercase(),
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = formattedSize,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@@ -0,0 +1,136 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
@Composable
fun Link(
modifier: Modifier = Modifier,
item: VkLinkDomain,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(horizontal = 8.dp)
.clip(RoundedCornerShape(4.dp))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
verticalAlignment = Alignment.CenterVertically
) {
var errorLoading by remember {
mutableStateOf(false)
}
// TODO: 11-Apr-25, Danil Nikolaev: extract to ui model
val preview by remember(item) {
derivedStateOf { item.photo?.getMaxSize()?.url }
}
val urlFirstChar by remember(item) {
derivedStateOf {
item.url
.replace("https://", "")
.replace("http://", "")
.first()
.toString()
}
}
if (preview != null && !errorLoading) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.size(width = 48.dp, height = 36.dp),
painter = rememberAsyncImagePainter(
model = preview,
imageLoader = LocalContext.current.imageLoader,
onState = {
errorLoading = it is AsyncImagePainter.State.Error
}
),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
Text(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp))
.size(width = 48.dp, height = 36.dp),
text = urlFirstChar,
textAlign = TextAlign.Center,
lineHeight = 36.sp,
fontWeight = FontWeight.Medium,
style = MaterialTheme.typography.titleLarge
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
if (item.title != null) {
Text(
text = item.title!!,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
LocalContentAlpha(
alpha = if (item.title != null) ContentAlpha.medium
else ContentAlpha.high
) {
Text(
text = item.url,
style = if (item.title != null) {
MaterialTheme.typography.bodyMedium
} else {
MaterialTheme.typography.bodyLarge
},
maxLines = if (item.title != null) 1 else 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@@ -0,0 +1,146 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.Icon
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.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun Previews(
modifier: Modifier = Modifier,
photos: ImmutableList<UiPreview>,
onClick: (index: Int) -> Unit = {},
onLongClick: (index: Int) -> Unit = {}
) {
DynamicPreviewGrid(
modifier = modifier,
photos = photos,
onClick = onClick,
onLongClick = onLongClick
)
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun DynamicPreviewGrid(
photos: ImmutableList<UiPreview>,
modifier: Modifier = Modifier,
onClick: (index: Int) -> Unit = {},
onLongClick: (index: Int) -> Unit = {}
) {
val spacing = 2.dp
val shape = RoundedCornerShape(8.dp)
BoxWithConstraints(modifier = modifier) {
val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val spacingPx = with(LocalDensity.current) { spacing.toPx() }
val rows = photos.chunked(3)
Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
rows.forEachIndexed { index, row ->
val aspectRatios = row.map { it.width.toFloat() / it.height }
val totalAspect = aspectRatios.sum()
Row(horizontalArrangement = Arrangement.spacedBy(spacing)) {
row.forEachIndexed { index, preview ->
val weight = aspectRatios[index] / totalAspect
val photoWidthPx = (maxWidthPx - spacingPx * (row.size - 1)) * weight
val height = photoWidthPx / aspectRatios[index]
val heightDp = with(LocalDensity.current) { height.toDp() }
Box(
modifier = Modifier
.height(heightDp)
.weight(weight)
.clip(shape),
contentAlignment = Alignment.Center
) {
AsyncImage(
model = preview.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.height(heightDp)
.clip(shape)
.combinedClickable(
onLongClick = { onLongClick(index) },
onClick = { onClick(index) }
)
)
if (preview.isVideo) {
IconButton(
onClick = { onClick(index) },
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
) {
Icon(
modifier = Modifier,
imageVector = Icons.Rounded.PlayArrow,
contentDescription = null,
tint = Color.White
)
}
}
}
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewDynamicPhotoGrid() {
val mockPhotos = listOf(
UiPreview(0, "https://picsum.photos/id/1011/600/400", 600, 400, false),
UiPreview(0, "https://picsum.photos/id/1012/500/500", 500, 500, false),
UiPreview(0, "https://picsum.photos/id/1013/400/600", 400, 600, false),
UiPreview(0, "https://picsum.photos/id/1014/600/600", 600, 600, false),
UiPreview(0, "https://picsum.photos/id/1015/800/600", 800, 600, false),
UiPreview(0, "https://picsum.photos/id/1016/700/500", 700, 500, false),
UiPreview(0, "https://picsum.photos/id/1018/600/600", 600, 600, false),
UiPreview(0, "https://picsum.photos/id/1020/600/800", 600, 800, false),
UiPreview(0, "https://picsum.photos/id/1021/800/800", 800, 800, false),
UiPreview(0, "https://picsum.photos/id/1022/500/700", 500, 700, false),
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.Center
) {
DynamicPreviewGrid(photos = mockPhotos.take(10).toImmutableList())
}
}
@@ -107,7 +107,7 @@ fun VkMessage.asPresentation(
): UiItem = when { ): UiItem = when {
action != null -> UiItem.ActionMessage( action != null -> UiItem.ActionMessage(
id = id, id = id,
conversationMessageId = cmId, cmId = cmId,
text = extractActionText( text = extractActionText(
resources = resourceProvider.resources, resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix), youPrefix = resourceProvider.getString(R.string.you_message_prefix),
@@ -118,7 +118,7 @@ fun VkMessage.asPresentation(
else -> UiItem.Message( else -> UiItem.Message(
id = id, id = id,
conversationMessageId = cmId, cmId = cmId,
text = extractTextWithVisualizedMentions( text = extractTextWithVisualizedMentions(
isOut = isOut, isOut = isOut,
originalText = text, originalText = text,
@@ -143,7 +143,8 @@ fun VkMessage.asPresentation(
}, },
isSelected = isSelected, isSelected = isSelected,
isPinned = isPinned, isPinned = isPinned,
isImportant = isImportant isImportant = isImportant,
attachments = attachments?.ifEmpty { null }
) )
} }
@@ -600,11 +601,20 @@ fun extractTextWithVisualizedMentions(
val startIndex = mention.indexRange.first val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last val endIndex = mention.indexRange.last
annotations += AnnotatedString.Range( annotations += if (isOut) {
AnnotatedString.Range(
item = SpanStyle(textDecoration = TextDecoration.Underline),
start = startIndex,
end = endIndex
)
} else {
AnnotatedString.Range(
item = SpanStyle(color = Color.Red), item = SpanStyle(color = Color.Red),
start = startIndex, start = startIndex,
end = endIndex end = endIndex
) )
}
annotations += AnnotatedString.Range( annotations += AnnotatedString.Range(
item = StringAnnotation(mention.id.toString()), item = StringAnnotation(mention.id.toString()),
tag = mention.idPrefix, tag = mention.idPrefix,
+10 -10
View File
@@ -5,14 +5,14 @@ compileSdk = "35"
versionCode = "10" versionCode = "10"
versionName = "0.2.0" versionName = "0.2.0"
agp = "8.9.1" agp = "8.10.0"
converterMoshi = "2.11.0" converterMoshi = "2.11.0"
eithernet = "2.0.0" eithernet = "2.0.0"
haze = "1.5.2" haze = "1.5.4"
kotlin = "2.1.20" kotlin = "2.1.20"
ksp = "2.1.20-1.0.32" ksp = "2.1.20-2.0.1"
compose-bom = "2025.04.00" compose-bom = "2025.05.00"
koin = "4.0.4" koin = "4.0.4"
accompanist = "0.37.2" accompanist = "0.37.2"
@@ -20,17 +20,17 @@ coil = "2.7.0"
coroutines = "1.10.2" coroutines = "1.10.2"
junit = "4.13.2" junit = "4.13.2"
chucker = "4.1.0" chucker = "4.1.0"
guava = "33.4.6-jre" guava = "33.4.8-jre"
lifecycle = "2.8.7" lifecycle = "2.9.0"
core-ktx = "1.15.0" core-ktx = "1.16.0"
material = "1.12.0" material = "1.12.0"
loggingInterceptor = "5.0.0-alpha.14" loggingInterceptor = "5.0.0-alpha.14"
moshi = "1.15.2" moshi = "1.15.2"
retrofit = "2.11.0" retrofit = "2.11.0"
room = "2.7.0" room = "2.7.1"
preference-ktx = "1.2.1" preference-ktx = "1.2.1"
nanokt = "1.2.0" nanokt = "1.2.0"
androidx-navigation = "2.8.9" androidx-navigation = "2.9.0"
serialization = "1.8.1" serialization = "1.8.1"
moduleGraph = "2.8.0" moduleGraph = "2.8.0"
@@ -71,7 +71,7 @@ kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-j
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-material3 = { module = "androidx.compose.material3:material3" } compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui = { module = "androidx.compose.ui:ui", version = "1.8.0-rc02" } compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" } compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" }