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