Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e696ec8c8 | |||
| b184d98670 | |||
| 43539139e8 | |||
| f45a106ed8 | |||
| 8b31e88caf | |||
| 4cb34327cf | |||
| c7b414b9f0 | |||
| 9a296c8b84 | |||
| ca569354fb | |||
| 89748b72ed | |||
| add67b6f8d | |||
| 25e7e39ed0 | |||
| 935d313257 | |||
| 5b5e8f8446 | |||
| f1892670da | |||
| d46c72f7e6 | |||
| 157c0c71fe | |||
| 988da07852 | |||
| f02822a011 | |||
| da9644cde1 | |||
| 9aa85d40c6 | |||
| f66123ba94 | |||
| b80babed9c | |||
| 85c5a10891 | |||
| 51356aa4dd | |||
| 807c23926e | |||
| 37a654790c | |||
| a36060654d | |||
| da5fa8d77a | |||
| 4d18c86f04 | |||
| 296c3ce7a0 | |||
| 0ae05709db | |||
| 3dbf2bd8a4 | |||
| 3b02e2ff61 | |||
| b21675d6f2 | |||
| 9e301af076 | |||
| 3fdb574971 | |||
| ad6e413bbb | |||
| 8dc47c3fa5 |
+12
-12
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-2
@@ -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,30 +25,18 @@ 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
|
||||
}
|
||||
}
|
||||
if (!found) return null
|
||||
return single
|
||||
override fun contains(element: T): Boolean {
|
||||
return values.contains(element)
|
||||
}
|
||||
|
||||
val size: Int get() = values.size
|
||||
|
||||
companion object {
|
||||
fun <T> copyOf(collection: Collection<T>): ImmutableList<T> =
|
||||
ImmutableList(collection.toList())
|
||||
@@ -67,3 +55,7 @@ class ImmutableList<T>(val values: List<T>) : Iterable<T> {
|
||||
}
|
||||
|
||||
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
|
||||
|
||||
fun <T> 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
package dev.meloda.fast.chatmaterials.presentation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
|
||||
|
||||
@Composable
|
||||
fun ChatMaterialItem(
|
||||
item: UiChatMaterial,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
when (item) {
|
||||
is UiChatMaterial.Photo -> {
|
||||
AsyncImage(
|
||||
model = item.previewUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
is UiChatMaterial.Video -> {
|
||||
AsyncImage(
|
||||
model = item.previewUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
|
||||
is UiChatMaterial.Audio -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(text = item.artist)
|
||||
}
|
||||
|
||||
Text(text = item.duration)
|
||||
}
|
||||
}
|
||||
|
||||
is UiChatMaterial.File -> {}
|
||||
|
||||
is UiChatMaterial.Link -> {}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -63,6 +63,7 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.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) {
|
||||
|
||||
+7
@@ -22,6 +22,7 @@ import dev.meloda.fast.model.api.data.AttachmentType
|
||||
import dev.meloda.fast.model.api.domain.VkAttachment
|
||||
import dev.meloda.fast.model.api.domain.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
|
||||
|
||||
+21
-24
@@ -47,30 +47,27 @@ fun FriendItem(
|
||||
val friendAvatar = friend.avatar?.extractUrl()
|
||||
|
||||
Box(modifier = Modifier.size(56.dp)) {
|
||||
if (friendAvatar == null) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
painter = painterResource(id = R.drawable.ic_account_circle_cut),
|
||||
contentDescription = "Avatar",
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = friendAvatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
friend.photo400Orig
|
||||
?.extractUrl()
|
||||
?.let(onPhotoClicked)
|
||||
},
|
||||
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
|
||||
)
|
||||
}
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
painter = painterResource(id = R.drawable.ic_account_circle_cut),
|
||||
contentDescription = "Avatar",
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
|
||||
)
|
||||
AsyncImage(
|
||||
model = friendAvatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
friend.photo400Orig
|
||||
?.extractUrl()
|
||||
?.let(onPhotoClicked)
|
||||
},
|
||||
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
|
||||
)
|
||||
|
||||
if (friend.onlineStatus.isOnline()) {
|
||||
Box(
|
||||
|
||||
+130
-4
@@ -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) },
|
||||
|
||||
+8
-6
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ fun ActionMessageItemPreview() {
|
||||
append("You pinned message \"wow hello there\"")
|
||||
},
|
||||
actionCmId = null,
|
||||
conversationMessageId = 2135
|
||||
cmId = 2135
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Create
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.messageshistory.model.SendingStatus
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
|
||||
@Composable
|
||||
fun BoxScope.DateStatus(
|
||||
modifier: Modifier = Modifier,
|
||||
dateContainerWidth: Dp,
|
||||
date: String,
|
||||
sendingStatus: SendingStatus,
|
||||
isImportant: Boolean,
|
||||
isPinned: Boolean,
|
||||
isEdited: Boolean,
|
||||
isOut: Boolean,
|
||||
isRead: Boolean
|
||||
) {
|
||||
val theme = LocalThemeConfig.current
|
||||
|
||||
Row(
|
||||
modifier = modifier.then(
|
||||
if (theme.enableAnimations) Modifier.animateContentSize()
|
||||
else Modifier
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isImportant) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.round_star_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
|
||||
}
|
||||
if (isPinned) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_round_push_pin_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.rotate(45f)
|
||||
)
|
||||
}
|
||||
if (isEdited) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> R.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) R.drawable.round_done_all_24
|
||||
else R.drawable.ic_round_done_24
|
||||
}
|
||||
|
||||
SendingStatus.FAILED -> R.drawable.round_error_outline_24
|
||||
}
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -27,12 +27,16 @@ import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+160
-149
@@ -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,151 +63,162 @@ fun MessageBubble(
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
}
|
||||
|
||||
val minDateContainerWidth by remember(isEdited, isOut, isPinned, isImportant) {
|
||||
derivedStateOf {
|
||||
val mainPart = if (isEdited) 50.dp else 30.dp
|
||||
val readIndicatorPart = if (isOut) 14.dp else 0.dp
|
||||
val pinnedIndicatorPart = if (isPinned) 14.dp else 0.dp
|
||||
val importantIndicatorPart = if (isImportant) 14.dp else 0.dp
|
||||
|
||||
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
|
||||
}
|
||||
}
|
||||
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
|
||||
val shouldShowBubble by remember(text) {
|
||||
derivedStateOf { text != null }
|
||||
}
|
||||
|
||||
var bubbleContainerWidth by remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
var attachmentsContainerWidth by remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
val shouldFill by remember(bubbleContainerWidth, attachmentsContainerWidth) {
|
||||
derivedStateOf {
|
||||
attachmentsContainerWidth >= bubbleContainerWidth
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
val minDateContainerWidth by remember(edited, isOut, pinned, important) {
|
||||
derivedStateOf {
|
||||
val mainPart = if (edited) 50.dp else 30.dp
|
||||
val readIndicatorPart = if (isOut) 14.dp else 0.dp
|
||||
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
|
||||
val importantIndicatorPart = if (important) 14.dp else 0.dp
|
||||
Column {
|
||||
if (shouldShowBubble) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned {
|
||||
bubbleContainerWidth = it.size.width
|
||||
}
|
||||
.widthIn(min = if (shouldFill) attachmentsContainerWidth.dp else 56.dp)
|
||||
.clip(
|
||||
if (attachments == null) RoundedCornerShape(24.dp)
|
||||
else RoundedCornerShape(
|
||||
topStart = 24.dp,
|
||||
topEnd = 24.dp,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 0.dp
|
||||
)
|
||||
)
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
MessageTextContainer(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.padding(end = 4.dp)
|
||||
.padding(end = dateContainerWidth)
|
||||
.padding(end = 4.dp)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
text = text,
|
||||
isOut = isOut,
|
||||
isSelected = isSelected,
|
||||
)
|
||||
|
||||
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
|
||||
}
|
||||
}
|
||||
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
|
||||
if (text != null) {
|
||||
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) {
|
||||
{
|
||||
Text(
|
||||
text = kotlin.run {
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
|
||||
text.spanStyles.map { spanStyleRange ->
|
||||
val updatedSpanStyle =
|
||||
if (spanStyleRange.item.color == Color.Red) {
|
||||
spanStyleRange.item.copy(color =
|
||||
if (isOut) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
} else {
|
||||
spanStyleRange.item
|
||||
}
|
||||
|
||||
builder.addStyle(
|
||||
style = updatedSpanStyle,
|
||||
start = spanStyleRange.start,
|
||||
end = spanStyleRange.end
|
||||
)
|
||||
}
|
||||
|
||||
text.paragraphStyles.forEach { style ->
|
||||
builder.addStyle(
|
||||
style = style.item,
|
||||
start = style.start,
|
||||
end = style.end
|
||||
)
|
||||
}
|
||||
|
||||
builder.toAnnotatedString()
|
||||
},
|
||||
if (attachments == null) {
|
||||
DateStatus(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.align(Alignment.Center)
|
||||
.padding(end = 4.dp)
|
||||
.padding(end = dateContainerWidth)
|
||||
.padding(end = 4.dp)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier)
|
||||
.padding(top = 3.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
.defaultMinSize(minWidth = dateContainerWidth),
|
||||
dateContainerWidth = dateContainerWidth,
|
||||
date = date,
|
||||
sendingStatus = sendingStatus,
|
||||
isImportant = isImportant,
|
||||
isPinned = isPinned,
|
||||
isEdited = isEdited,
|
||||
isOut = isOut,
|
||||
isRead = isRead
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
SelectionContainer {
|
||||
textLambda.invoke()
|
||||
}
|
||||
} else {
|
||||
textLambda.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.defaultMinSize(minWidth = dateContainerWidth)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
if (important) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.round_star_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
if (attachments != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
attachmentsContainerWidth = it.size.width
|
||||
}
|
||||
.clip(
|
||||
if (!shouldShowBubble) RoundedCornerShape(24.dp)
|
||||
else RoundedCornerShape(
|
||||
bottomEnd = 24.dp,
|
||||
bottomStart = 24.dp,
|
||||
topStart = 0.dp,
|
||||
topEnd = 0.dp
|
||||
)
|
||||
)
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
Attachments(
|
||||
modifier = Modifier,
|
||||
attachments = attachments,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (pinned) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.ic_round_push_pin_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.rotate(45f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (edited) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = date.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
val dateStatusBackground = if (theme.darkMode) Color.Black.copy(alpha = 0.5f)
|
||||
else Color.White.copy(alpha = 0.5f)
|
||||
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) UiR.drawable.round_done_all_24
|
||||
else UiR.drawable.ic_round_done_24
|
||||
}
|
||||
|
||||
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
|
||||
}
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
)
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
DateStatus(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = 6.dp, end = 6.dp)
|
||||
.widthIn(min = 42.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(dateStatusBackground)
|
||||
.padding(4.dp),
|
||||
dateContainerWidth = dateContainerWidth,
|
||||
date = date,
|
||||
sendingStatus = sendingStatus,
|
||||
isImportant = isImportant,
|
||||
isPinned = isPinned,
|
||||
isEdited = isEdited,
|
||||
isOut = isOut,
|
||||
isRead = isRead
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Bubble() {
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = AnnotatedString("Some cool text"),
|
||||
isOut = true,
|
||||
date = "19:01",
|
||||
isEdited = true,
|
||||
isRead = true,
|
||||
sendingStatus = SendingStatus.SENT,
|
||||
isPinned = true,
|
||||
isImportant = true,
|
||||
isSelected = false,
|
||||
attachments = emptyImmutableList()
|
||||
)
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun MessageTextContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
text: AnnotatedString?,
|
||||
isOut: Boolean,
|
||||
isSelected: Boolean,
|
||||
) {
|
||||
if (text != null) {
|
||||
if (isSelected) {
|
||||
SelectionContainer {
|
||||
MessageText(
|
||||
modifier = modifier,
|
||||
text = text,
|
||||
isOut = isOut,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
MessageText(
|
||||
modifier = modifier,
|
||||
text = text,
|
||||
isOut = isOut,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageText(
|
||||
modifier: Modifier = Modifier,
|
||||
text: AnnotatedString,
|
||||
isOut: Boolean,
|
||||
) {
|
||||
val replacedColor = if (isOut) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
val newText = remember(text) {
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
|
||||
text.spanStyles.map { spanStyleRange ->
|
||||
val updatedSpanStyle =
|
||||
if (spanStyleRange.item.color == Color.Red) {
|
||||
spanStyleRange.item.copy(color = replacedColor)
|
||||
} else {
|
||||
spanStyleRange.item
|
||||
}
|
||||
|
||||
builder.addStyle(
|
||||
style = updatedSpanStyle,
|
||||
start = spanStyleRange.start,
|
||||
end = spanStyleRange.end
|
||||
)
|
||||
}
|
||||
|
||||
text.paragraphStyles.forEach { style ->
|
||||
builder.addStyle(
|
||||
style = style.item,
|
||||
start = style.start,
|
||||
end = style.end
|
||||
)
|
||||
}
|
||||
|
||||
builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = newText,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MessageTextPreview() {
|
||||
MessageTextContainer(
|
||||
modifier = Modifier,
|
||||
text = AnnotatedString("Some cool text"),
|
||||
isOut = true,
|
||||
isSelected = false
|
||||
)
|
||||
}
|
||||
+4
-1
@@ -70,7 +70,10 @@ fun MessagesHistoryRoute(
|
||||
onMessageLongClicked = viewModel::onMessageLongClicked,
|
||||
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
|
||||
onBoldRequested = viewModel::onBoldClicked,
|
||||
onItalicRequested = viewModel::onItalicClicked,
|
||||
onUnderlineRequested = viewModel::onUnderlineClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
|
||||
+297
-28
@@ -1,7 +1,13 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.os.Build
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
@@ -56,6 +62,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -67,11 +74,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalTextToolbar
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.TextToolbar
|
||||
import androidx.compose.ui.platform.TextToolbarStatus
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -133,7 +144,10 @@ fun MessagesHistoryScreen(
|
||||
onMessageLongClicked: (Long) -> Unit = {},
|
||||
onPinnedMessageClicked: (Long) -> Unit = {},
|
||||
onUnpinMessageButtonClicked: () -> Unit = {},
|
||||
onDeleteSelectedButtonClicked: () -> Unit = {}
|
||||
onDeleteSelectedButtonClicked: () -> Unit = {},
|
||||
onBoldRequested: () -> Unit = {},
|
||||
onItalicRequested: () -> Unit = {},
|
||||
onUnderlineRequested: () -> Unit = {},
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -289,7 +303,7 @@ fun MessagesHistoryScreen(
|
||||
Text(
|
||||
text = when {
|
||||
screenState.isLoading -> stringResource(id = UiR.string.title_loading)
|
||||
selectedMessages.size > 0 -> "(${selectedMessages.size})"
|
||||
selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
|
||||
else -> screenState.title
|
||||
},
|
||||
maxLines = 1,
|
||||
@@ -305,14 +319,17 @@ fun MessagesHistoryScreen(
|
||||
else onClose()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (selectedMessages.isEmpty()) {
|
||||
Icons.AutoMirrored.Rounded.ArrowBack
|
||||
} else {
|
||||
Icons.Rounded.Close
|
||||
},
|
||||
contentDescription = "Back button"
|
||||
)
|
||||
Crossfade(targetState = selectedMessages.isEmpty()) { state ->
|
||||
Icon(
|
||||
imageVector = if (state) {
|
||||
Icons.AutoMirrored.Rounded.ArrowBack
|
||||
} else {
|
||||
Icons.Rounded.Close
|
||||
},
|
||||
contentDescription = if (state) "Close button"
|
||||
else "Back button"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
@@ -557,24 +574,37 @@ fun MessagesHistoryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = screenState.message,
|
||||
onValueChange = onMessageInputChanged,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(id = UiR.string.message_input_hint),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
val view = LocalView.current
|
||||
val textToolbar = remember {
|
||||
CustomTextToolbar(
|
||||
view = view,
|
||||
onBoldRequested = onBoldRequested,
|
||||
onItalicRequested = onItalicRequested,
|
||||
onUnderlineRequested = onUnderlineRequested,
|
||||
onLinkRequested = {}
|
||||
)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalTextToolbar provides textToolbar) {
|
||||
TextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = screenState.message,
|
||||
onValueChange = onMessageInputChanged,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(id = UiR.string.message_input_hint),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachmentRotation = remember { Animatable(0f) }
|
||||
@@ -687,3 +717,242 @@ fun MessagesHistoryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CustomTextToolbar(
|
||||
private val view: View,
|
||||
private var onBoldRequested: (() -> Unit)? = null,
|
||||
private var onItalicRequested: (() -> Unit)? = null,
|
||||
private var onUnderlineRequested: (() -> Unit)? = null,
|
||||
private var onLinkRequested: (() -> Unit)? = null
|
||||
) : TextToolbar {
|
||||
private var actionMode: android.view.ActionMode? = null
|
||||
private val textActionModeCallback: TextActionModeCallback =
|
||||
TextActionModeCallback(onActionModeDestroy = { actionMode = null })
|
||||
override var status: TextToolbarStatus = TextToolbarStatus.Hidden
|
||||
private set
|
||||
|
||||
override fun showMenu(
|
||||
rect: Rect,
|
||||
onCopyRequested: (() -> Unit)?,
|
||||
onPasteRequested: (() -> Unit)?,
|
||||
onCutRequested: (() -> Unit)?,
|
||||
onSelectAllRequested: (() -> Unit)?,
|
||||
onAutofillRequested: (() -> Unit)?
|
||||
) {
|
||||
textActionModeCallback.rect = rect
|
||||
textActionModeCallback.onCopyRequested = onCopyRequested
|
||||
textActionModeCallback.onCutRequested = onCutRequested
|
||||
textActionModeCallback.onPasteRequested = onPasteRequested
|
||||
textActionModeCallback.onSelectAllRequested = onSelectAllRequested
|
||||
textActionModeCallback.onAutofillRequested = onAutofillRequested
|
||||
textActionModeCallback.onBoldRequested = onBoldRequested
|
||||
textActionModeCallback.onItalicRequested = onItalicRequested
|
||||
textActionModeCallback.onUnderlineRequested = onUnderlineRequested
|
||||
textActionModeCallback.onLinkRequested = onLinkRequested
|
||||
|
||||
if (actionMode == null) {
|
||||
status = TextToolbarStatus.Shown
|
||||
actionMode =
|
||||
TextToolbarHelperMethods.startActionMode(
|
||||
view,
|
||||
FloatingTextActionModeCallback(textActionModeCallback),
|
||||
android.view.ActionMode.TYPE_FLOATING
|
||||
)
|
||||
} else {
|
||||
actionMode?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun showMenu(
|
||||
rect: Rect,
|
||||
onCopyRequested: (() -> Unit)?,
|
||||
onPasteRequested: (() -> Unit)?,
|
||||
onCutRequested: (() -> Unit)?,
|
||||
onSelectAllRequested: (() -> Unit)?
|
||||
) {
|
||||
showMenu(
|
||||
rect = rect,
|
||||
onCopyRequested = onCopyRequested,
|
||||
onPasteRequested = onPasteRequested,
|
||||
onCutRequested = onCutRequested,
|
||||
onSelectAllRequested = onSelectAllRequested,
|
||||
onAutofillRequested = null
|
||||
)
|
||||
}
|
||||
|
||||
override fun hide() {
|
||||
status = TextToolbarStatus.Hidden
|
||||
actionMode?.finish()
|
||||
actionMode = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is here to ensure that the classes that use this API will get verified and can be AOT
|
||||
* compiled. It is expected that this class will soft-fail verification, but the classes which use
|
||||
* this method will pass.
|
||||
*/
|
||||
internal object TextToolbarHelperMethods {
|
||||
fun startActionMode(
|
||||
view: View,
|
||||
actionModeCallback: android.view.ActionMode.Callback,
|
||||
type: Int
|
||||
): android.view.ActionMode? {
|
||||
return view.startActionMode(actionModeCallback, type)
|
||||
}
|
||||
|
||||
fun invalidateContentRect(actionMode: android.view.ActionMode) {
|
||||
actionMode.invalidateContentRect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FloatingTextActionModeCallback(private val callback: TextActionModeCallback) :
|
||||
android.view.ActionMode.Callback2() {
|
||||
override fun onActionItemClicked(mode: android.view.ActionMode?, item: MenuItem?): Boolean {
|
||||
return callback.onActionItemClicked(mode, item)
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
|
||||
return callback.onCreateActionMode(mode, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
|
||||
return callback.onPrepareActionMode(mode, menu)
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: android.view.ActionMode?) {
|
||||
callback.onDestroyActionMode()
|
||||
}
|
||||
|
||||
override fun onGetContentRect(
|
||||
mode: android.view.ActionMode?,
|
||||
view: View?,
|
||||
outRect: android.graphics.Rect?
|
||||
) {
|
||||
val rect = callback.rect
|
||||
outRect?.set(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
class TextActionModeCallback(
|
||||
val onActionModeDestroy: (() -> Unit)? = null,
|
||||
var rect: Rect = Rect.Zero,
|
||||
var onCopyRequested: (() -> Unit)? = null,
|
||||
var onPasteRequested: (() -> Unit)? = null,
|
||||
var onCutRequested: (() -> Unit)? = null,
|
||||
var onSelectAllRequested: (() -> Unit)? = null,
|
||||
var onAutofillRequested: (() -> Unit)? = null,
|
||||
var onBoldRequested: (() -> Unit)? = null,
|
||||
var onItalicRequested: (() -> Unit)? = null,
|
||||
var onUnderlineRequested: (() -> Unit)? = null,
|
||||
var onLinkRequested: (() -> Unit)? = null
|
||||
) {
|
||||
fun onCreateActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
|
||||
requireNotNull(menu) { "onCreateActionMode requires a non-null menu" }
|
||||
requireNotNull(mode) { "onCreateActionMode requires a non-null mode" }
|
||||
|
||||
onCopyRequested?.let { addMenuItem(menu, MenuItemOption.Copy) }
|
||||
onPasteRequested?.let { addMenuItem(menu, MenuItemOption.Paste) }
|
||||
onCutRequested?.let { addMenuItem(menu, MenuItemOption.Cut) }
|
||||
onSelectAllRequested?.let { addMenuItem(menu, MenuItemOption.SelectAll) }
|
||||
if (onAutofillRequested != null && Build.VERSION.SDK_INT >= 26) {
|
||||
addMenuItem(menu, MenuItemOption.Autofill)
|
||||
}
|
||||
onBoldRequested?.let { addMenuItem(menu, MenuItemOption.Bold) }
|
||||
onItalicRequested?.let { addMenuItem(menu, MenuItemOption.Italic) }
|
||||
onUnderlineRequested?.let { addMenuItem(menu, MenuItemOption.Underline) }
|
||||
onLinkRequested?.let { addMenuItem(menu, MenuItemOption.Link) }
|
||||
return true
|
||||
}
|
||||
|
||||
// this method is called to populate new menu items when the actionMode was invalidated
|
||||
fun onPrepareActionMode(mode: android.view.ActionMode?, menu: Menu?): Boolean {
|
||||
if (mode == null || menu == null) return false
|
||||
updateMenuItems(menu)
|
||||
// should return true so that new menu items are populated
|
||||
return true
|
||||
}
|
||||
|
||||
fun onActionItemClicked(mode: android.view.ActionMode?, item: MenuItem?): Boolean {
|
||||
when (item!!.itemId) {
|
||||
MenuItemOption.Copy.ordinal -> onCopyRequested?.invoke()
|
||||
MenuItemOption.Paste.ordinal -> onPasteRequested?.invoke()
|
||||
MenuItemOption.Cut.ordinal -> onCutRequested?.invoke()
|
||||
MenuItemOption.SelectAll.ordinal -> onSelectAllRequested?.invoke()
|
||||
MenuItemOption.Autofill.ordinal -> onAutofillRequested?.invoke()
|
||||
MenuItemOption.Bold.ordinal -> onBoldRequested?.invoke()
|
||||
MenuItemOption.Italic.ordinal -> onItalicRequested?.invoke()
|
||||
MenuItemOption.Underline.ordinal -> onUnderlineRequested?.invoke()
|
||||
MenuItemOption.Link.ordinal -> onLinkRequested?.invoke()
|
||||
else -> return false
|
||||
}
|
||||
mode?.finish()
|
||||
return true
|
||||
}
|
||||
|
||||
fun onDestroyActionMode() {
|
||||
onActionModeDestroy?.invoke()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun updateMenuItems(menu: Menu) {
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Copy, onCopyRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Paste, onPasteRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Cut, onCutRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Autofill, onAutofillRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Bold, onBoldRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Italic, onItalicRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Underline, onUnderlineRequested)
|
||||
addOrRemoveMenuItem(menu, MenuItemOption.Link, onLinkRequested)
|
||||
}
|
||||
|
||||
private fun addMenuItem(menu: Menu, item: MenuItemOption) {
|
||||
menu
|
||||
.add(0, item.ordinal, item.order, item.titleResource)
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
|
||||
}
|
||||
|
||||
private fun addOrRemoveMenuItem(menu: Menu, item: MenuItemOption, callback: (() -> Unit)?) {
|
||||
when {
|
||||
callback != null && menu.findItem(item.ordinal) == null -> addMenuItem(menu, item)
|
||||
callback == null && menu.findItem(item.ordinal) != null -> menu.removeItem(item.ordinal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class MenuItemOption {
|
||||
Copy,
|
||||
Paste,
|
||||
Cut,
|
||||
SelectAll,
|
||||
Autofill,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Link;
|
||||
|
||||
val titleResource: Int
|
||||
get() =
|
||||
when (this) {
|
||||
Copy -> android.R.string.copy
|
||||
Paste -> android.R.string.paste
|
||||
Cut -> android.R.string.cut
|
||||
SelectAll -> android.R.string.selectAll
|
||||
Autofill ->
|
||||
if (Build.VERSION.SDK_INT <= 26) {
|
||||
UiR.string.autofill
|
||||
} else {
|
||||
android.R.string.autofill
|
||||
}
|
||||
|
||||
Bold -> UiR.string.bold
|
||||
Italic -> UiR.string.italic
|
||||
Underline -> UiR.string.underline
|
||||
Link -> UiR.string.link
|
||||
}
|
||||
|
||||
/** This item will be shown before all items that have order greater than this value. */
|
||||
val order = ordinal
|
||||
}
|
||||
|
||||
+78
-2
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package dev.meloda.fast.messageshistory.presentation.attachments
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.meloda.fast.model.api.data.AttachmentType
|
||||
import dev.meloda.fast.model.api.domain.VkAttachment
|
||||
import dev.meloda.fast.model.api.domain.VkAudioDomain
|
||||
import dev.meloda.fast.model.api.domain.VkFileDomain
|
||||
import dev.meloda.fast.model.api.domain.VkLinkDomain
|
||||
import dev.meloda.fast.model.api.domain.VkPhotoDomain
|
||||
import dev.meloda.fast.model.api.domain.VkVideoDomain
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
|
||||
private val previewTypes = listOf(
|
||||
AttachmentType.PHOTO,
|
||||
AttachmentType.VIDEO
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Attachments(
|
||||
modifier: Modifier = Modifier,
|
||||
attachments: ImmutableList<out VkAttachment>,
|
||||
onClick: (VkAttachment) -> Unit = {},
|
||||
onLongClick: (VkAttachment) -> Unit = {}
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (attachments.isEmpty()) return
|
||||
|
||||
val previewAttachments by remember(attachments) {
|
||||
derivedStateOf {
|
||||
attachments.values.filter { it.type in previewTypes }
|
||||
}
|
||||
}
|
||||
|
||||
val nonPreviewAttachments by remember(attachments) {
|
||||
derivedStateOf {
|
||||
attachments.values.filterNot { it.type in previewTypes }
|
||||
.sortedBy { it.type.ordinal }
|
||||
}
|
||||
}
|
||||
|
||||
if (previewAttachments.isNotEmpty()) {
|
||||
Previews(
|
||||
modifier = Modifier,
|
||||
photos = previewAttachments
|
||||
.map(VkAttachment::asUiPhoto)
|
||||
.toImmutableList(),
|
||||
onClick = { index ->
|
||||
onClick(previewAttachments[index])
|
||||
},
|
||||
onLongClick = { index ->
|
||||
onLongClick(previewAttachments[index])
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
nonPreviewAttachments.forEach { attachment ->
|
||||
when (attachment.type) {
|
||||
AttachmentType.AUDIO -> {
|
||||
Audio(
|
||||
item = attachment as VkAudioDomain,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
AttachmentType.FILE -> {
|
||||
File(
|
||||
item = attachment as VkFileDomain,
|
||||
modifier = Modifier,
|
||||
onClick = { onClick(attachment) },
|
||||
onLongClick = { onLongClick(attachment) }
|
||||
)
|
||||
}
|
||||
|
||||
AttachmentType.LINK -> {
|
||||
Link(
|
||||
item = attachment as VkLinkDomain,
|
||||
modifier = Modifier,
|
||||
onClick = { onClick(attachment) },
|
||||
onLongClick = { onLongClick(attachment) }
|
||||
)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun VkAttachment.asUiPhoto(): UiPreview {
|
||||
return when (this) {
|
||||
is VkPhotoDomain -> {
|
||||
val size = this.getDefault()!!
|
||||
UiPreview(
|
||||
id = this.id,
|
||||
url = size.url,
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
isVideo = false
|
||||
)
|
||||
}
|
||||
|
||||
is VkVideoDomain -> {
|
||||
val size = this.getDefault() ?: VkVideoDomain.VideoImage(
|
||||
width = 1280,
|
||||
height = 720,
|
||||
url = "",
|
||||
withPadding = false
|
||||
)
|
||||
|
||||
UiPreview(
|
||||
id = this.id,
|
||||
url = size.url,
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
isVideo = true
|
||||
)
|
||||
}
|
||||
|
||||
is VkFileDomain -> {
|
||||
when {
|
||||
this.preview?.video != null -> {
|
||||
val video = this.preview?.video!!
|
||||
|
||||
UiPreview(
|
||||
id = id,
|
||||
url = video.src,
|
||||
width = video.width,
|
||||
height = video.height,
|
||||
isVideo = true
|
||||
)
|
||||
}
|
||||
|
||||
this.preview?.photo != null -> {
|
||||
val photoSize = this.preview?.photo?.sizes?.first()!!
|
||||
|
||||
UiPreview(
|
||||
id = id,
|
||||
url = photoSize.src,
|
||||
width = photoSize.width,
|
||||
height = photoSize.height,
|
||||
isVideo = false
|
||||
)
|
||||
}
|
||||
|
||||
else -> error("Unsupported type: $this")
|
||||
}
|
||||
}
|
||||
|
||||
else -> error("Unsupported type: $this")
|
||||
}
|
||||
}
|
||||
|
||||
data class UiPreview(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val isVideo: Boolean
|
||||
)
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.meloda.fast.messageshistory.presentation.attachments
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.model.api.domain.VkAudioDomain
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun Audio(
|
||||
modifier: Modifier = Modifier,
|
||||
item: VkAudioDomain
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.size(36.dp)
|
||||
.padding(4.dp),
|
||||
painter = painterResource(R.drawable.round_play_arrow_24),
|
||||
contentDescription = null,
|
||||
tint = contentColorFor(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
LocalContentAlpha(alpha = ContentAlpha.medium) {
|
||||
Text(
|
||||
text = item.artist,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 11-Apr-25, Danil Nikolaev: extract to ui model
|
||||
val formattedDuration by remember(item) {
|
||||
derivedStateOf {
|
||||
val duration = item.duration
|
||||
|
||||
val days = duration / (24 * 3600)
|
||||
val hours = (duration % (24 * 3600)) / 3600
|
||||
val minutes = (duration % 3600) / 60
|
||||
val seconds = duration % 60
|
||||
|
||||
val args = mutableListOf<Int>()
|
||||
if (days > 0) args.add(days)
|
||||
if (hours > 0) args.add(hours)
|
||||
args.add(minutes)
|
||||
args.add(seconds)
|
||||
|
||||
val builder = StringBuilder()
|
||||
if (days > 0) builder.append("%02d:")
|
||||
if (hours > 0) builder.append("%02d:")
|
||||
builder.append("%d:%02d")
|
||||
|
||||
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = formattedDuration,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package dev.meloda.fast.messageshistory.presentation.attachments
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import dev.meloda.fast.common.util.AndroidUtils
|
||||
import dev.meloda.fast.model.api.domain.VkFileDomain
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
|
||||
@Composable
|
||||
fun File(
|
||||
modifier: Modifier = Modifier,
|
||||
item: VkFileDomain,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
var errorLoading by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
// TODO: 11-Apr-25, Danil Nikolaev: extract to ui model
|
||||
val preview by remember(item) {
|
||||
derivedStateOf {
|
||||
when (val preview = item.preview) {
|
||||
null -> null
|
||||
|
||||
else -> {
|
||||
when {
|
||||
preview.photo != null -> {
|
||||
val size = preview.photo?.sizes?.maxByOrNull { it.width }
|
||||
size?.src
|
||||
}
|
||||
|
||||
preview.video != null -> {
|
||||
val size = preview.video?.src
|
||||
size
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val formattedSize by remember(item) {
|
||||
derivedStateOf {
|
||||
AndroidUtils.bytesToHumanReadableSize(item.size.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
if (preview != null && !errorLoading) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.size(width = 48.dp, height = 36.dp),
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = preview,
|
||||
imageLoader = LocalContext.current.imageLoader,
|
||||
onState = {
|
||||
errorLoading = it is AsyncImagePainter.State.Error
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp))
|
||||
.size(width = 48.dp, height = 36.dp),
|
||||
text = item.ext.uppercase(),
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
LocalContentAlpha(alpha = ContentAlpha.medium) {
|
||||
Text(
|
||||
text = formattedSize,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package dev.meloda.fast.messageshistory.presentation.attachments
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import dev.meloda.fast.model.api.domain.VkLinkDomain
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
|
||||
@Composable
|
||||
fun Link(
|
||||
modifier: Modifier = Modifier,
|
||||
item: VkLinkDomain,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
var errorLoading by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
// TODO: 11-Apr-25, Danil Nikolaev: extract to ui model
|
||||
val preview by remember(item) {
|
||||
derivedStateOf { item.photo?.getMaxSize()?.url }
|
||||
}
|
||||
val urlFirstChar by remember(item) {
|
||||
derivedStateOf {
|
||||
item.url
|
||||
.replace("https://", "")
|
||||
.replace("http://", "")
|
||||
.first()
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
|
||||
if (preview != null && !errorLoading) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.size(width = 48.dp, height = 36.dp),
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = preview,
|
||||
imageLoader = LocalContext.current.imageLoader,
|
||||
onState = {
|
||||
errorLoading = it is AsyncImagePainter.State.Error
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp))
|
||||
.size(width = 48.dp, height = 36.dp),
|
||||
text = urlFirstChar,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 36.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (item.title != null) {
|
||||
Text(
|
||||
text = item.title!!,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
LocalContentAlpha(
|
||||
alpha = if (item.title != null) ContentAlpha.medium
|
||||
else ContentAlpha.high
|
||||
) {
|
||||
Text(
|
||||
text = item.url,
|
||||
style = if (item.title != null) {
|
||||
MaterialTheme.typography.bodyMedium
|
||||
} else {
|
||||
MaterialTheme.typography.bodyLarge
|
||||
},
|
||||
maxLines = if (item.title != null) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package dev.meloda.fast.messageshistory.presentation.attachments
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import dev.meloda.fast.ui.components.IconButton
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun Previews(
|
||||
modifier: Modifier = Modifier,
|
||||
photos: ImmutableList<UiPreview>,
|
||||
onClick: (index: Int) -> Unit = {},
|
||||
onLongClick: (index: Int) -> Unit = {}
|
||||
) {
|
||||
DynamicPreviewGrid(
|
||||
modifier = modifier,
|
||||
photos = photos,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun DynamicPreviewGrid(
|
||||
photos: ImmutableList<UiPreview>,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (index: Int) -> Unit = {},
|
||||
onLongClick: (index: Int) -> Unit = {}
|
||||
) {
|
||||
val spacing = 2.dp
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
|
||||
val spacingPx = with(LocalDensity.current) { spacing.toPx() }
|
||||
|
||||
val rows = photos.chunked(3)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
|
||||
rows.forEachIndexed { index, row ->
|
||||
val aspectRatios = row.map { it.width.toFloat() / it.height }
|
||||
val totalAspect = aspectRatios.sum()
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing)) {
|
||||
row.forEachIndexed { index, preview ->
|
||||
val weight = aspectRatios[index] / totalAspect
|
||||
val photoWidthPx = (maxWidthPx - spacingPx * (row.size - 1)) * weight
|
||||
val height = photoWidthPx / aspectRatios[index]
|
||||
val heightDp = with(LocalDensity.current) { height.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(heightDp)
|
||||
.weight(weight)
|
||||
.clip(shape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = preview.url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.height(heightDp)
|
||||
.clip(shape)
|
||||
.combinedClickable(
|
||||
onLongClick = { onLongClick(index) },
|
||||
onClick = { onClick(index) }
|
||||
)
|
||||
)
|
||||
|
||||
if (preview.isVideo) {
|
||||
IconButton(
|
||||
onClick = { onClick(index) },
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier,
|
||||
imageVector = Icons.Rounded.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewDynamicPhotoGrid() {
|
||||
val mockPhotos = listOf(
|
||||
UiPreview(0, "https://picsum.photos/id/1011/600/400", 600, 400, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1012/500/500", 500, 500, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1013/400/600", 400, 600, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1014/600/600", 600, 600, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1015/800/600", 800, 600, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1016/700/500", 700, 500, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1018/600/600", 600, 600, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1020/600/800", 600, 800, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1021/800/800", 800, 800, false),
|
||||
UiPreview(0, "https://picsum.photos/id/1022/500/700", 500, 700, false),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DynamicPreviewGrid(photos = mockPhotos.take(10).toImmutableList())
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -107,7 +107,7 @@ fun VkMessage.asPresentation(
|
||||
): UiItem = when {
|
||||
action != null -> UiItem.ActionMessage(
|
||||
id = id,
|
||||
conversationMessageId = cmId,
|
||||
cmId = cmId,
|
||||
text = extractActionText(
|
||||
resources = resourceProvider.resources,
|
||||
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
|
||||
@@ -118,7 +118,7 @@ fun VkMessage.asPresentation(
|
||||
|
||||
else -> UiItem.Message(
|
||||
id = id,
|
||||
conversationMessageId = cmId,
|
||||
cmId = cmId,
|
||||
text = extractTextWithVisualizedMentions(
|
||||
isOut = isOut,
|
||||
originalText = text,
|
||||
@@ -143,7 +143,8 @@ fun VkMessage.asPresentation(
|
||||
},
|
||||
isSelected = isSelected,
|
||||
isPinned = isPinned,
|
||||
isImportant = isImportant
|
||||
isImportant = isImportant,
|
||||
attachments = attachments?.ifEmpty { null }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -600,11 +601,20 @@ fun extractTextWithVisualizedMentions(
|
||||
val startIndex = mention.indexRange.first
|
||||
val endIndex = mention.indexRange.last
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = SpanStyle(color = Color.Red),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
annotations += if (isOut) {
|
||||
AnnotatedString.Range(
|
||||
item = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
} else {
|
||||
AnnotatedString.Range(
|
||||
item = SpanStyle(color = Color.Red),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
}
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = StringAnnotation(mention.id.toString()),
|
||||
tag = mention.idPrefix,
|
||||
|
||||
+12
-12
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user