diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt index 15a5c5b1..243e8b59 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt @@ -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 } } ) diff --git a/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt b/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt index 583d1c34..339822c9 100644 --- a/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt +++ b/core/common/src/main/kotlin/dev/meloda/fast/common/extensions/Extensions.kt @@ -23,6 +23,20 @@ fun MutableList.addIf(element: T, condition: () -> Boolean) { if (condition.invoke()) add(element) } +fun MutableList.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 Flow.listenValue( coroutineScope: CoroutineScope, action: suspend (T) -> Unit diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt index c997ac8b..81c2d582 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt @@ -33,7 +33,8 @@ interface MessagesRepository { randomId: Long, message: String?, replyTo: Long?, - attachments: List? + attachments: List?, + formatData: VkMessage.FormatData? ): ApiResult suspend fun markAsRead( diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt index a0c4c186..a5170aaa 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt @@ -184,14 +184,16 @@ class MessagesRepositoryImpl( randomId: Long, message: String?, replyTo: Long?, - attachments: List? + attachments: List?, + formatData: VkMessage.FormatData? ): ApiResult = 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() diff --git a/core/database/schemas/dev.meloda.fast.database.AccountsDatabase/3.json b/core/database/schemas/dev.meloda.fast.database.AccountsDatabase/3.json new file mode 100644 index 00000000..9fd99b9b --- /dev/null +++ b/core/database/schemas/dev.meloda.fast.database.AccountsDatabase/3.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/dev.meloda.fast.database.CacheDatabase/10.json b/core/database/schemas/dev.meloda.fast.database.CacheDatabase/10.json new file mode 100644 index 00000000..bddff7b1 --- /dev/null +++ b/core/database/schemas/dev.meloda.fast.database.CacheDatabase/10.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt index 3694a50e..ae131c56 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt @@ -33,7 +33,8 @@ interface MessagesUseCase : BaseUseCase { randomId: Long, message: String?, replyTo: Long?, - attachments: List? + attachments: List?, + formatData: VkMessage.FormatData? ): Flow> fun markAsRead( diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt index 166e39f4..214624c7 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt @@ -57,14 +57,16 @@ class MessagesUseCaseImpl( randomId: Long, message: String?, replyTo: Long?, - attachments: List? + attachments: List?, + formatData: VkMessage.FormatData? ): Flow> = flowNewState { repository.send( peerId = peerId, randomId = randomId, message = message, replyTo = replyTo, - attachments = attachments + attachments = attachments, + formatData = formatData ).mapToState() } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/PhotoSize.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/PhotoSize.kt new file mode 100644 index 00000000..13cadc9e --- /dev/null +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/PhotoSize.kt @@ -0,0 +1,8 @@ +package dev.meloda.fast.model + +data class PhotoSize( + val height: Int, + val width: Int, + val type: String, + val url: String +) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt index a4ea83a7..ced68273 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/AttachmentType.kt @@ -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"), diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt index cbbb1ebc..60e45013 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkFileData.kt @@ -27,7 +27,9 @@ data class VkFileData( ) { @JsonClass(generateAdapter = true) - data class Photo(val sizes: List) { + data class Photo( + val sizes: List + ) { @JsonClass(generateAdapter = true) data class Size( diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt index 719fc8b7..aae58e36 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkPhotoData.kt @@ -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 ) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt index 3774ff1b..42e50d7e 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoData.kt @@ -23,7 +23,7 @@ data class VkVideoData( @Json(name = "is_favorite") val isFavorite: Boolean?, @Json(name = "image") val image: List?, @Json(name = "first_frame") val firstFrame: List?, - @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" ) } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt index bb7ecee4..fab25a24 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWallReplyData.kt @@ -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, - val likes: Likes, + val post_id: Long?, + val owner_id: Long?, + val parents_stack: List?, + val likes: Likes?, val reply_to_user: Int?, val reply_to_comment: Int? ) { diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/FormatDataType.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/FormatDataType.kt index 553614d6..49d2dcdc 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/FormatDataType.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/FormatDataType.kt @@ -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 } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt index 72f1da13..ee15ac3a 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkPhotoDomain.kt @@ -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, + val sizes: List, 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) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt index 30a44756..0d98b983 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoDomain.kt @@ -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) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt index cb29fa8d..fe59e877 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt @@ -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? = null + val attachments: List? = null, + val formatData: VkMessage.FormatData? = null ) { val map: Map @@ -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 { diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt index 500882a1..fb2c97de 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.ui.util import androidx.compose.runtime.Immutable @Immutable -class ImmutableList(val values: List) : Iterable { +class ImmutableList(val values: List) : Collection { constructor(size: Int, init: (index: Int) -> T) : this(MutableList(size, init)) @@ -25,30 +25,18 @@ class ImmutableList(val values: List) : Iterable { 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): 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 - } - } - if (!found) return null - return single + override fun contains(element: T): Boolean { + return values.contains(element) } - val size: Int get() = values.size - companion object { fun copyOf(collection: Collection): ImmutableList = ImmutableList(collection.toList()) @@ -67,3 +55,7 @@ class ImmutableList(val values: List) : Iterable { } fun emptyImmutableList(): ImmutableList = ImmutableList(emptyList()) + +fun immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) + +fun ImmutableList?.orEmpty(): ImmutableList = this ?: emptyImmutableList() diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index 20927524..6bea13ed 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -107,6 +107,7 @@ %1$d файла %1$d файлов %1$d файлов + Клип Голосовое сообщение Ссылка Мини-приложение @@ -263,4 +264,9 @@ В архив Архивировать чат? В архив + Автозаполнение + Жирный + Курсив + Подчёркнутый + Ссылка diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 96f11fe1..a6f052a3 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ %1$d files %1$d files + Clip Voice message Link Mini App @@ -338,4 +339,10 @@ Unmark as spam Are you sure you want to unmark this message as spam? Copied to clipboard + + Autofill + Bold + Italic + Underline + Link diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 930f0059..df320637 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -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" diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt index 3e11bd5f..a3daa3a8 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt @@ -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 diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/Logo.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/Logo.kt index 7bceedf4..fe304aa1 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/Logo.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/Logo.kt @@ -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 ) } } diff --git a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialItem.kt b/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialItem.kt deleted file mode 100644 index 50f9078d..00000000 --- a/feature/chatmaterials/src/main/kotlin/dev/meloda/fast/chatmaterials/presentation/ChatMaterialItem.kt +++ /dev/null @@ -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 -> {} - } -} diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt index 22a11715..1db8488f 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt @@ -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) { diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt index baca6a66..57e62d68 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/util/ConversationDomainMapper.kt @@ -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 diff --git a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt index debe5891..69afbf65 100644 --- a/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt +++ b/feature/friends/src/main/kotlin/dev/meloda/fast/friends/presentation/FriendItem.kt @@ -47,30 +47,27 @@ fun FriendItem( val friendAvatar = friend.avatar?.extractUrl() Box(modifier = Modifier.size(56.dp)) { - if (friendAvatar == null) { - Image( - modifier = Modifier - .fillMaxSize() - .clip(CircleShape), - painter = painterResource(id = R.drawable.ic_account_circle_cut), - contentDescription = "Avatar", - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) - ) - } else { - AsyncImage( - model = friendAvatar, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(CircleShape) - .clickable { - friend.photo400Orig - ?.extractUrl() - ?.let(onPhotoClicked) - }, - placeholder = painterResource(id = R.drawable.ic_account_circle_cut) - ) - } + Image( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + painter = painterResource(id = R.drawable.ic_account_circle_cut), + contentDescription = "Avatar", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) + ) + AsyncImage( + model = friendAvatar, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .clickable { + friend.photo400Orig + ?.extractUrl() + ?.let(onPhotoClicked) + }, + placeholder = painterResource(id = R.drawable.ic_account_circle_cut) + ) if (friend.onlineStatus.isOnline()) { Box( diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index 56759c9b..0d5f50d4 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -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>() + + 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) }, diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt index f7fab77e..1588e6b2 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt @@ -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? + ) : 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) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt index 8a095842..1d3e95b9 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/ActionMessageItem.kt @@ -62,7 +62,7 @@ fun ActionMessageItemPreview() { append("You pinned message \"wow hello there\"") }, actionCmId = null, - conversationMessageId = 2135 + cmId = 2135 ) ) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/DateStatus.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/DateStatus.kt new file mode 100644 index 00000000..dfe4ee04 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/DateStatus.kt @@ -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 + ) + } + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt index 6f67315c..57ebe097 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt @@ -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 ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt index de257b74..ebe5f4bc 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt @@ -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?, + onClick: (VkAttachment) -> Unit = {}, + onLongClick: (VkAttachment) -> Unit = {} ) { val theme = LocalThemeConfig.current val backgroundColor = if (!isOut) { @@ -63,151 +63,162 @@ fun MessageBubble( MaterialTheme.colorScheme.onPrimaryContainer } + val minDateContainerWidth by remember(isEdited, isOut, isPinned, isImportant) { + derivedStateOf { + val mainPart = if (isEdited) 50.dp else 30.dp + val readIndicatorPart = if (isOut) 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 + } + } + + val dateContainerWidth by animateDpAsState( + targetValue = minDateContainerWidth, + label = "dateContainerWidth" + ) + + 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) { - 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 { - val mainPart = if (edited) 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 + 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 + ) + ) + .background(backgroundColor) + .padding( + horizontal = 8.dp, + vertical = 6.dp + ) + .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), + ) { + MessageTextContainer( + modifier = Modifier + .padding(2.dp) + .padding(end = 4.dp) + .padding(end = dateContainerWidth) + .padding(end = 4.dp) + .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), + text = text, + isOut = isOut, + isSelected = isSelected, + ) - mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart - } - } - - val dateContainerWidth by animateDpAsState( - targetValue = minDateContainerWidth, - 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 - } - ) - } 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() - }, + if (attachments == null) { + DateStatus( 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) + .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( - 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) + 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 ) - 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)) + val dateStatusBackground = if (theme.darkMode) Color.Black.copy(alpha = 0.5f) + else Color.White.copy(alpha = 0.5f) - 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 - ) + CompositionLocalProvider(LocalContentColor provides contentColor) { + DateStatus( + modifier = Modifier + .align(Alignment.BottomEnd) + .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 + ) + } } } } } } + +@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() + ) +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageText.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageText.kt new file mode 100644 index 00000000..6ff7fd33 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageText.kt @@ -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 + ) +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt index 776dfe65..93bf589d 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt @@ -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( diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index 628abf2a..5c1b4df8 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -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,14 +319,17 @@ fun MessagesHistoryScreen( else onClose() } ) { - Icon( - imageVector = if (selectedMessages.isEmpty()) { - Icons.AutoMirrored.Rounded.ArrowBack - } else { - Icons.Rounded.Close - }, - contentDescription = "Back button" - ) + Crossfade(targetState = selectedMessages.isEmpty()) { state -> + Icon( + imageVector = if (state) { + Icons.AutoMirrored.Rounded.ArrowBack + } else { + Icons.Rounded.Close + }, + contentDescription = if (state) "Close button" + else "Back button" + ) + } } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), @@ -557,24 +574,37 @@ fun MessagesHistoryScreen( } } - TextField( - modifier = Modifier.weight(1f), - value = screenState.message, - onValueChange = onMessageInputChanged, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = Color.Transparent, - focusedContainerColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - ), - placeholder = { - Text( - text = stringResource(id = UiR.string.message_input_hint), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) + 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, + onValueChange = onMessageInputChanged, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ), + placeholder = { + Text( + text = stringResource(id = UiR.string.message_input_hint), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } 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 +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index c5fc000b..185739cb 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -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) + } ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt index 7f97b086..d6c1f213 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt @@ -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 ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt new file mode 100644 index 00000000..db04d387 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt @@ -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, + 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 +) diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Audio.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Audio.kt new file mode 100644 index 00000000..f036091e --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Audio.kt @@ -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() + 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 + ) + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/File.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/File.kt new file mode 100644 index 00000000..c1dc780c --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/File.kt @@ -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 + ) + } + } + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Link.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Link.kt new file mode 100644 index 00000000..aa0fc9e4 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Link.kt @@ -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 + ) + } + } + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Previews.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Previews.kt new file mode 100644 index 00000000..4a848eb7 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Previews.kt @@ -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, + onClick: (index: Int) -> Unit = {}, + onLongClick: (index: Int) -> Unit = {} +) { + DynamicPreviewGrid( + modifier = modifier, + photos = photos, + onClick = onClick, + onLongClick = onLongClick + ) +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +fun DynamicPreviewGrid( + photos: ImmutableList, + 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()) + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt index f795b053..508fc13b 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt @@ -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( - item = SpanStyle(color = Color.Red), - start = startIndex, - end = endIndex - ) + 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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5651cd74..0850344c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }