39 Commits

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