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
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
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.HazeMaterials
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.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen
@@ -71,6 +73,18 @@ fun MainScreen(
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 profileImageUrl by remember(user) {
derivedStateOf { user?.photo100 }
@@ -195,7 +209,7 @@ fun MainScreen(
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
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)
}
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(
coroutineScope: CoroutineScope,
action: suspend (T) -> Unit
@@ -33,7 +33,8 @@ interface MessagesRepository {
randomId: Long,
message: String?,
replyTo: Long?,
attachments: List<VkAttachment>?
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain>
suspend fun markAsRead(
@@ -184,14 +184,16 @@ class MessagesRepositoryImpl(
randomId: Long,
message: String?,
replyTo: Long?,
attachments: List<VkAttachment>?
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
attachments = attachments
attachments = attachments,
formatData = formatData
)
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,
message: String?,
replyTo: Long?,
attachments: List<VkAttachment>?
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>>
fun markAsRead(
@@ -57,14 +57,16 @@ class MessagesUseCaseImpl(
randomId: Long,
message: String?,
replyTo: Long?,
attachments: List<VkAttachment>?
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState {
repository.send(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
attachments = attachments
attachments = attachments,
formatData = formatData
).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"),
PHOTO("photo"),
VIDEO("video"),
AUDIO("audio"),
FILE("doc"),
AUDIO("audio"),
LINK("link"),
AUDIO_MESSAGE("audio_message"),
MINI_APP("mini_app"),
@@ -27,7 +27,9 @@ data class VkFileData(
) {
@JsonClass(generateAdapter = true)
data class Photo(val sizes: List<Size>) {
data class Photo(
val sizes: List<Size>
) {
@JsonClass(generateAdapter = true)
data class Size(
@@ -2,6 +2,7 @@ package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.PhotoSize
import dev.meloda.fast.model.api.domain.VkPhotoDomain
@JsonClass(generateAdapter = true)
@@ -35,7 +36,14 @@ data class VkPhotoData(
ownerId = ownerId,
hasTags = hasTags == true,
accessKey = accessKey,
sizes = sizes,
sizes = sizes.map { size ->
PhotoSize(
height = size.height,
width = size.width,
type = size.type,
url = size.url
)
},
text = text,
userId = userId
)
@@ -23,7 +23,7 @@ data class VkVideoData(
@Json(name = "is_favorite") val isFavorite: Boolean?,
@Json(name = "image") val image: List<Image>?,
@Json(name = "first_frame") val firstFrame: List<FirstFrame>?,
@Json(name = "files") val files: File?
@Json(name = "files") val files: File?,
) : VkAttachmentData {
@JsonClass(generateAdapter = true)
@@ -73,6 +73,7 @@ data class VkVideoData(
accessKey = accessKey,
title = title,
views = views,
duration = duration
duration = duration,
isShortVideo = type == "short_video"
)
}
@@ -9,10 +9,10 @@ data class VkWallReplyData(
val from_id: Long,
val date: Int,
val text: String,
val post_id: Long,
val owner_id: Long,
val parents_stack: List<Int>,
val likes: Likes,
val post_id: Long?,
val owner_id: Long?,
val parents_stack: List<Int>?,
val likes: Likes?,
val reply_to_user: Int?,
val reply_to_comment: Int?
) {
@@ -3,6 +3,10 @@ package dev.meloda.fast.model.api.domain
enum class FormatDataType {
BOLD, ITALIC, UNDERLINE, URL;
override fun toString(): String {
return super.toString().lowercase()
}
companion object {
fun parse(value: String): FormatDataType? =
entries.firstOrNull { it.name.lowercase() == value }
@@ -1,7 +1,7 @@
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.VkPhotoData
import java.util.Stack
@@ -13,7 +13,7 @@ data class VkPhotoDomain(
val ownerId: Long,
val hasTags: Boolean,
val accessKey: String?,
val sizes: List<VkPhotoData.Size>,
val sizes: List<PhotoSize>,
val text: String?,
val userId: Long?
) : VkAttachment {
@@ -35,11 +35,15 @@ data class VkPhotoDomain(
sizesChars.push(SIZE_TYPE_2560_2048)
}
fun getMaxSize(): VkPhotoData.Size? {
fun getMaxSize(): PhotoSize? {
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) {
if (size.type == type.toString()) return size
}
@@ -47,7 +51,7 @@ data class VkPhotoDomain(
return null
}
fun getSizeOrSmaller(type: Char): VkPhotoData.Size? {
fun getSizeOrSmaller(type: Char): PhotoSize? {
val photoStack = sizesChars.clone() as Stack<*>
val sizeIndex = photoStack.search(type)
@@ -13,7 +13,8 @@ data class VkVideoDomain(
val accessKey: String?,
val title: String,
val views: Int,
val duration: Int
val duration: Int,
val isShortVideo: Boolean
) : VkAttachment {
override val type: AttachmentType = AttachmentType.VIDEO
@@ -22,6 +23,10 @@ data class VkVideoDomain(
return images.find { it.width == width }
}
fun getDefault(): VideoImage? {
return imageForWidthAtLeast(720)
}
fun imageForWidthAtLeast(width: Int): VideoImage? {
var certainImages = images.sortedByDescending { it.width }
var containsVertical = false
@@ -36,9 +41,11 @@ data class VkVideoDomain(
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)
@@ -2,6 +2,7 @@ package dev.meloda.fast.model.api.requests
import dev.meloda.fast.model.api.asInt
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesGetHistoryRequest(
val count: Int? = null,
@@ -38,7 +39,8 @@ data class MessagesSendRequest(
val disableMentions: Boolean? = null,
val doNotParseLinks: 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>
@@ -54,6 +56,13 @@ data class MessagesSendRequest(
disableMentions?.let { this["disable_mentions"] = it.asInt().toString() }
doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().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
// attachments?.let {
@@ -3,7 +3,7 @@ package dev.meloda.fast.ui.util
import androidx.compose.runtime.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))
@@ -25,29 +25,17 @@ class ImmutableList<T>(val values: List<T>) : Iterable<T> {
return values.mapIndexed(transform).toImmutableList()
}
fun singleOrNull(): T? {
return if (values.size == 1) this[0] else null
override fun isEmpty(): Boolean = values.isEmpty()
override val size: Int get() = values.size
override fun containsAll(elements: Collection<T>): Boolean {
return values.containsAll(elements)
}
fun isEmpty(): Boolean = values.isEmpty()
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
override fun contains(element: T): Boolean {
return values.contains(element)
}
}
if (!found) return null
return single
}
val size: Int get() = values.size
companion object {
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> 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_many">%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_link">Ссылка</string>
<string name="message_attachments_mini_app">Мини-приложение</string>
@@ -263,4 +264,9 @@
<string name="conversation_context_action_archive">В архив</string>
<string name="confirm_archive_conversation">Архивировать чат?</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>
+7
View File
@@ -83,6 +83,7 @@
<string name="message_attachments_files_many">%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_link">Link</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_text">Are you sure you want to unmark this message as spam?</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>
+1 -8
View File
@@ -17,7 +17,7 @@ plugins {
androidComponents {
onVariants { variant ->
variant.buildConfigFields.apply {
variant.buildConfigFields?.apply {
put(
"sdkPackage",
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 {
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.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.password
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
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.LoginUserBannedArguments
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.TextFieldErrorText
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
@Composable
@@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -36,7 +37,7 @@ fun Logo(modifier: Modifier = Modifier) {
val size = LocalSizeConfig.current
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 userSettings: UserSettings = koinInject()
@@ -78,7 +79,8 @@ fun Logo(modifier: Modifier = Modifier) {
Text(
text = stringResource(id = R.string.fast_messenger),
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.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
@@ -116,7 +117,7 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
val currentTabReselected = LocalReselectedTab.current[Conversations] ?: false
val currentTabReselected = LocalReselectedTab.current[ConversationsGraph] ?: false
LaunchedEffect(currentTabReselected) {
if (currentTabReselected) {
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.VkConversation
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.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
@@ -728,6 +729,12 @@ private fun getAttachmentUiText(
attachment: VkAttachment,
size: Int = 1,
): UiText {
if (attachment.type == AttachmentType.VIDEO &&
(attachment as? VkVideoDomain)?.isShortVideo == true
) {
return UiText.Resource(UiR.string.message_attachments_clip)
}
if (attachment.type.isMultiple()) {
return when (attachment.type) {
AttachmentType.PHOTO -> UiR.plurals.attachment_photos
@@ -47,7 +47,6 @@ fun FriendItem(
val friendAvatar = friend.avatar?.extractUrl()
Box(modifier = Modifier.size(56.dp)) {
if (friendAvatar == null) {
Image(
modifier = Modifier
.fillMaxSize()
@@ -56,7 +55,6 @@ fun FriendItem(
contentDescription = "Avatar",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
} else {
AsyncImage(
model = friendAvatar,
contentDescription = null,
@@ -70,7 +68,6 @@ fun FriendItem(
},
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
)
}
if (friend.onlineStatus.isOnline()) {
Box(
@@ -7,9 +7,15 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
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.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.style.TextDecoration
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.extensions.listenValue
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.provider.ResourceProvider
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.model.BaseError
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.VkMessage
import dev.meloda.fast.network.VkErrorCode
@@ -98,6 +106,11 @@ interface MessagesHistoryViewModel {
fun onUnpinMessageClicked()
fun onDeleteSelectedMessagesClicked()
fun onBoldClicked()
fun onItalicClicked()
fun onUnderlineClicked()
fun onLinkClicked()
}
class MessagesHistoryViewModelImpl(
@@ -346,8 +359,7 @@ class MessagesHistoryViewModelImpl(
else ActionMode.Send
)
}
screenState.setValue { old -> old.copy(message = newText) }
updateStyles()
}
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) {
val message = event.message
@@ -800,8 +924,9 @@ class MessagesHistoryViewModelImpl(
pinnedAt = null,
// TODO: 04-Apr-25, Danil Nikolaev: implement
formatData = null,
formatData = formatData,
)
formatData = formatData.copy(items = emptyList())
sendingMessages += newMessage
messages.setValue { old -> listOf(newMessage).plus(old) }
syncUiMessages()
@@ -818,7 +943,8 @@ class MessagesHistoryViewModelImpl(
randomId = newMessage.randomId,
message = newMessage.text,
replyTo = null,
attachments = null
attachments = null,
formatData = newMessage.formatData
).listenValue(viewModelScope) { state ->
state.processState(
any = { sendingMessages.remove(newMessage) },
@@ -2,15 +2,16 @@ package dev.meloda.fast.messageshistory.model
import androidx.compose.ui.text.AnnotatedString
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
sealed class UiItem(
open val id: Long,
val cmId: Long
open val cmId: Long
) {
data class Message(
override val id: Long,
val conversationMessageId: Long,
override val cmId: Long,
val text: AnnotatedString?,
val isOut: Boolean,
val fromId: Long,
@@ -27,13 +28,14 @@ sealed class UiItem(
val sendingStatus: SendingStatus,
val isSelected: Boolean,
val isPinned: Boolean,
val isImportant: Boolean
) : UiItem(id, conversationMessageId)
val isImportant: Boolean,
val attachments: List<VkAttachment>?
) : UiItem(id, cmId)
data class ActionMessage(
override val id: Long,
val conversationMessageId: Long,
override val cmId: Long,
val text: AnnotatedString,
val actionCmId: Long?
) : UiItem(id, conversationMessageId)
) : UiItem(id, cmId)
}
@@ -62,7 +62,7 @@ fun ActionMessageItemPreview() {
append("You pinned message \"wow hello there\"")
},
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.imageLoader
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.util.ImmutableList.Companion.toImmutableList
@Composable
fun IncomingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) {
Row(
modifier = modifier
@@ -87,12 +91,15 @@ fun IncomingMessageBubble(
text = message.text,
isOut = false,
date = message.date,
edited = message.isEdited,
isEdited = message.isEdited,
isRead = message.isRead,
sendingStatus = message.sendingStatus,
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
isPinned = message.isPinned,
isImportant = message.isImportant,
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.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.draw.rotate
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.R as UiR
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Composable
fun MessageBubble(
modifier: Modifier = Modifier,
text: AnnotatedString?,
isOut: Boolean,
date: String?,
edited: Boolean,
date: String,
isEdited: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus,
pinned: Boolean,
important: Boolean,
isSelected: Boolean
isPinned: Boolean,
isImportant: Boolean,
isSelected: Boolean,
attachments: ImmutableList<VkAttachment>?,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) {
val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) {
@@ -63,24 +63,12 @@ fun MessageBubble(
MaterialTheme.colorScheme.onPrimaryContainer
}
CompositionLocalProvider(LocalContentColor provides contentColor) {
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) {
val minDateContainerWidth by remember(isEdited, isOut, isPinned, isImportant) {
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 pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
val importantIndicatorPart = if (important) 14.dp else 0.dp
val pinnedIndicatorPart = if (isPinned) 14.dp else 0.dp
val importantIndicatorPart = if (isImportant) 14.dp else 0.dp
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
}
@@ -91,123 +79,146 @@ fun MessageBubble(
label = "dateContainerWidth"
)
if (text != null) {
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) {
{
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
val shouldShowBubble by remember(text) {
derivedStateOf { text != null }
}
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
)
}
text.paragraphStyles.forEach { style ->
builder.addStyle(
style = style.item,
start = style.start,
end = style.end
.background(backgroundColor)
.padding(
horizontal = 8.dp,
vertical = 6.dp
)
}
builder.toAnnotatedString()
},
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
MessageTextContainer(
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
.padding(end = 4.dp)
.padding(end = dateContainerWidth)
.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
.align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
if (important) {
Icon(
painter = painterResource(UiR.drawable.round_star_24),
contentDescription = null,
modifier = Modifier.size(14.dp)
.padding(bottom = 6.dp, end = 6.dp)
.widthIn(min = 42.dp)
.clip(RoundedCornerShape(24.dp))
.background(dateStatusBackground)
.padding(4.dp),
dateContainerWidth = dateContainerWidth,
date = date,
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(
text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall
)
Spacer(modifier = Modifier.width(4.dp))
if (isOut) {
Icon(
modifier = Modifier.size(14.dp),
painter = painterResource(
when (sendingStatus) {
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
SendingStatus.SENT -> {
if (isRead) UiR.drawable.round_done_all_24
else UiR.drawable.ic_round_done_24
}
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
}
),
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
else LocalContentColor.current,
contentDescription = null
)
}
}
}
}
}
@Preview
@Composable
private fun Bubble() {
MessageBubble(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
isOut = true,
date = "19:01",
isEdited = true,
isRead = true,
sendingStatus = SendingStatus.SENT,
isPinned = true,
isImportant = true,
isSelected = false,
attachments = emptyImmutableList()
)
}
@@ -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,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
onBoldRequested = viewModel::onBoldClicked,
onItalicRequested = viewModel::onItalicClicked,
onUnderlineRequested = viewModel::onUnderlineClicked
)
HandleDialogs(
@@ -1,7 +1,13 @@
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.annotation.VisibleForTesting
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
@@ -56,6 +62,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -67,11 +74,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalTextToolbar
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.stringResource
import androidx.compose.ui.text.input.TextFieldValue
@@ -133,7 +144,10 @@ fun MessagesHistoryScreen(
onMessageLongClicked: (Long) -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}
onDeleteSelectedButtonClicked: () -> Unit = {},
onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {},
onUnderlineRequested: () -> Unit = {},
) {
val view = LocalView.current
val coroutineScope = rememberCoroutineScope()
@@ -289,7 +303,7 @@ fun MessagesHistoryScreen(
Text(
text = when {
screenState.isLoading -> stringResource(id = UiR.string.title_loading)
selectedMessages.size > 0 -> "(${selectedMessages.size})"
selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
else -> screenState.title
},
maxLines = 1,
@@ -305,15 +319,18 @@ fun MessagesHistoryScreen(
else onClose()
}
) {
Crossfade(targetState = selectedMessages.isEmpty()) { state ->
Icon(
imageVector = if (selectedMessages.isEmpty()) {
imageVector = if (state) {
Icons.AutoMirrored.Rounded.ArrowBack
} else {
Icons.Rounded.Close
},
contentDescription = "Back button"
contentDescription = if (state) "Close button"
else "Back button"
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
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(
modifier = Modifier.weight(1f),
value = screenState.message,
@@ -575,6 +604,7 @@ fun MessagesHistoryScreen(
)
}
)
}
val scope = rememberCoroutineScope()
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
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -20,11 +23,13 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
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.meloda.fast.datastore.AppSettings
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.util.ImmutableList
import androidx.core.net.toUri
import dev.meloda.fast.model.api.domain.VkFileDomain
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -49,9 +59,63 @@ fun MessagesList(
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {}
) {
val context = LocalContext.current
val theme = LocalThemeConfig.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(
modifier = modifier
.fillMaxWidth()
@@ -141,7 +205,13 @@ fun MessagesList(
)
else Modifier
),
message = item
message = item,
onClick = { attachment ->
onAttachmentClick(item, attachment)
},
onLongClick = { attachment ->
onAttachmentLongClick(item, attachment)
}
)
} else {
IncomingMessageBubble(
@@ -155,7 +225,13 @@ fun MessagesList(
)
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.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.common.extensions.orDots
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.util.ImmutableList.Companion.toImmutableList
@Composable
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) {
Row(
modifier = modifier
@@ -41,12 +44,15 @@ fun OutgoingMessageBubble(
text = message.text,
isOut = true,
date = message.date,
edited = message.isEdited,
isEdited = message.isEdited,
isRead = message.isRead,
sendingStatus = message.sendingStatus,
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
isPinned = message.isPinned,
isImportant = message.isImportant,
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 {
action != null -> UiItem.ActionMessage(
id = id,
conversationMessageId = cmId,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
@@ -118,7 +118,7 @@ fun VkMessage.asPresentation(
else -> UiItem.Message(
id = id,
conversationMessageId = cmId,
cmId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
@@ -143,7 +143,8 @@ fun VkMessage.asPresentation(
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant
isImportant = isImportant,
attachments = attachments?.ifEmpty { null }
)
}
@@ -600,11 +601,20 @@ fun extractTextWithVisualizedMentions(
val startIndex = mention.indexRange.first
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),
start = startIndex,
end = endIndex
)
}
annotations += AnnotatedString.Range(
item = StringAnnotation(mention.id.toString()),
tag = mention.idPrefix,
+10 -10
View File
@@ -5,14 +5,14 @@ compileSdk = "35"
versionCode = "10"
versionName = "0.2.0"
agp = "8.9.1"
agp = "8.10.0"
converterMoshi = "2.11.0"
eithernet = "2.0.0"
haze = "1.5.2"
haze = "1.5.4"
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"
accompanist = "0.37.2"
@@ -20,17 +20,17 @@ coil = "2.7.0"
coroutines = "1.10.2"
junit = "4.13.2"
chucker = "4.1.0"
guava = "33.4.6-jre"
lifecycle = "2.8.7"
core-ktx = "1.15.0"
guava = "33.4.8-jre"
lifecycle = "2.9.0"
core-ktx = "1.16.0"
material = "1.12.0"
loggingInterceptor = "5.0.0-alpha.14"
moshi = "1.15.2"
retrofit = "2.11.0"
room = "2.7.0"
room = "2.7.1"
preference-ktx = "1.2.1"
nanokt = "1.2.0"
androidx-navigation = "2.8.9"
androidx-navigation = "2.9.0"
serialization = "1.8.1"
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-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 = { module = "androidx.compose.ui:ui-tooling" }
compose-material3-windowsize = { module = "androidx.compose.material3:material3-window-size-class" }