Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c22300e9 | ||
|
|
d9a20e40b0 |
+10
@@ -87,3 +87,13 @@ postgres_data/
|
|||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
*.txt
|
*.txt
|
||||||
|
|
||||||
|
# Android / Kotlin Mobile
|
||||||
|
client-mobile/.gradle/
|
||||||
|
client-mobile/.idea/
|
||||||
|
client-mobile/.run/
|
||||||
|
client-mobile/build/
|
||||||
|
client-mobile/.cxx/
|
||||||
|
client-mobile/local.properties
|
||||||
|
client-mobile/*.iml
|
||||||
|
client-mobile/.kotlin/
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Tue Apr 14 00:27:15 MSK 2026
|
|
||||||
gradle.version=8.5
|
|
||||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Tue Apr 14 00:13:58 MSK 2026
|
|
||||||
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
|
||||||
@@ -61,6 +61,10 @@ android {
|
|||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.8"
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
}
|
}
|
||||||
|
// Включаем оптимизации компилятора Compose
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="core.notifications.data.ForkFirebaseMessagingService"
|
android:name="core.notifications.data.KnotFirebaseMessagingService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import navigation.AppNavigation
|
import navigation.AppNavigation
|
||||||
import core.presentation.theme.ForkMessengerTheme
|
import core.presentation.theme.KnotTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -38,7 +38,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ForkMessengerTheme {
|
KnotTheme {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Цветная иконка для notification -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#6C5CE7"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
|
||||||
|
</vector>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ForkMessenger</string>
|
<string name="app_name">Knot</string>
|
||||||
<string name="login">Login</string>
|
<string name="login">Login</string>
|
||||||
<string name="register">Register</string>
|
<string name="register">Register</string>
|
||||||
<string name="username">Username</string>
|
<string name="username">Username</string>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ForkMessenger</string>
|
<string name="app_name">Knot</string>
|
||||||
<string name="login">Войти</string>
|
<string name="login">Войти</string>
|
||||||
<string name="register">Регистрация</string>
|
<string name="register">Регистрация</string>
|
||||||
<string name="username">Имя пользователя</string>
|
<string name="username">Имя пользователя</string>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
private val api: ChatApi,
|
private val api: ChatApi,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val serverConfig: ServerConfig,
|
private val serverConfig: ServerConfig,
|
||||||
private val messageDao: core.database.data.MessageDao,
|
|
||||||
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
private val gson = com.google.gson.Gson()
|
private val gson = com.google.gson.Gson()
|
||||||
@@ -35,10 +34,8 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
// Кэш отключён - всегда возвращаем пустой поток
|
||||||
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
|
return kotlinx.coroutines.flow.flowOf(emptyList())
|
||||||
entities.map { it.toDomain(baseUrl, gson) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
|
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
|
||||||
@@ -93,21 +90,19 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
try {
|
try {
|
||||||
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
|
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId")
|
||||||
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
||||||
}
|
}
|
||||||
// Обновляем локальную БД
|
|
||||||
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveMessage(message: Message) {
|
override suspend fun saveMessage(message: Message) {
|
||||||
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}")
|
// Кэш отключён
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteLocalMessage(messageId: String) {
|
override suspend fun deleteLocalMessage(messageId: String) {
|
||||||
messageDao.deleteMessage(messageId)
|
// Локальное удаление не поддерживается без кэша
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun uploadMedia(file: java.io.File): String {
|
override suspend fun uploadMedia(file: java.io.File): String {
|
||||||
@@ -145,7 +140,6 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
|
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
|
||||||
api.deleteMessage(messageId, forEveryone)
|
api.deleteMessage(messageId, forEveryone)
|
||||||
messageDao.deleteMessage(messageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun editMessage(messageId: String, content: String): Message {
|
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||||
|
|||||||
@@ -23,24 +23,6 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
|
||||||
return core.database.data.MessageEntity(
|
|
||||||
id = id,
|
|
||||||
chatId = chatId,
|
|
||||||
senderId = senderId,
|
|
||||||
senderName = senderName,
|
|
||||||
senderAvatar = senderAvatar,
|
|
||||||
content = content,
|
|
||||||
sequenceId = sequenceId,
|
|
||||||
createdAt = createdAt,
|
|
||||||
mediaType = mediaType.name.lowercase(),
|
|
||||||
mediaJson = gson.toJson(media),
|
|
||||||
reactionsJson = gson.toJson(reactions),
|
|
||||||
isRead = isRead,
|
|
||||||
replyToId = replyTo?.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
||||||
val domainMediaType = when (type) {
|
val domainMediaType = when (type) {
|
||||||
"gif" -> MediaType.GIF
|
"gif" -> MediaType.GIF
|
||||||
@@ -86,79 +68,6 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
|
||||||
return core.database.data.MessageEntity(
|
|
||||||
id = id,
|
|
||||||
chatId = chatId ?: "",
|
|
||||||
senderId = senderId ?: "",
|
|
||||||
senderName = sender?.displayName ?: "Unknown",
|
|
||||||
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
|
|
||||||
content = content,
|
|
||||||
sequenceId = sequenceId ?: 0,
|
|
||||||
createdAt = createdAt ?: "",
|
|
||||||
mediaType = type ?: "text",
|
|
||||||
mediaJson = gson.toJson(media),
|
|
||||||
reactionsJson = gson.toJson(reactions),
|
|
||||||
isRead = senderId == currentUserId,
|
|
||||||
replyToId = replyTo?.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message {
|
|
||||||
val mediaTypeEnum = when (mediaType) {
|
|
||||||
"gif" -> MediaType.GIF
|
|
||||||
"image", "photo" -> MediaType.IMAGE
|
|
||||||
"video" -> MediaType.VIDEO
|
|
||||||
"audio", "voice" -> MediaType.AUDIO
|
|
||||||
"file" -> MediaType.FILE
|
|
||||||
else -> MediaType.TEXT
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
|
|
||||||
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
|
|
||||||
gson.fromJson(mediaJson, mediaTypeToken)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
emptyList()
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
|
|
||||||
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
|
|
||||||
gson.fromJson(reactionsJson, reactionsTypeToken)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
try {
|
|
||||||
val mapType = object : com.google.gson.reflect.TypeToken<Map<String, Int>>() {}.type
|
|
||||||
val map: Map<String, Int> = gson.fromJson(reactionsJson, mapType) ?: emptyMap()
|
|
||||||
map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) }
|
|
||||||
} catch (innerE: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
return Message(
|
|
||||||
id = id,
|
|
||||||
chatId = chatId,
|
|
||||||
senderId = senderId,
|
|
||||||
senderName = senderName,
|
|
||||||
senderAvatar = senderAvatar,
|
|
||||||
content = content,
|
|
||||||
sequenceId = sequenceId,
|
|
||||||
createdAt = createdAt,
|
|
||||||
media = mediaDtos.map {
|
|
||||||
chats.domain.model.Media(
|
|
||||||
id = it.id ?: java.util.UUID.randomUUID().toString(),
|
|
||||||
type = it.type ?: "unknown",
|
|
||||||
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
|
|
||||||
filename = it.filename,
|
|
||||||
size = it.size,
|
|
||||||
duration = it.duration
|
|
||||||
)
|
|
||||||
},
|
|
||||||
mediaType = mediaTypeEnum,
|
|
||||||
reactions = reactionDtos.associate { it.emoji to it.count },
|
|
||||||
isRead = isRead
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.ensureAbsoluteUrl(baseUrl: String): String {
|
fun String.ensureAbsoluteUrl(baseUrl: String): String {
|
||||||
return if (this.startsWith("http")) {
|
return if (this.startsWith("http")) {
|
||||||
this
|
this
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import chats.data.remote.api.ChatApi
|
|||||||
import chats.data.remote.signalr.ChatHubClient
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
import chats.data.repository.ChatRepositoryImpl
|
import chats.data.repository.ChatRepositoryImpl
|
||||||
import chats.domain.repository.ChatRepository
|
import chats.domain.repository.ChatRepository
|
||||||
import core.database.data.MessageDao
|
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -30,10 +29,9 @@ object ChatModule {
|
|||||||
api: ChatApi,
|
api: ChatApi,
|
||||||
tokenManager: TokenManager,
|
tokenManager: TokenManager,
|
||||||
serverConfig: ServerConfig,
|
serverConfig: ServerConfig,
|
||||||
messageDao: MessageDao,
|
|
||||||
hubClient: chats.data.remote.signalr.ChatHubClient
|
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
): ChatRepository {
|
): ChatRepository {
|
||||||
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
|
return ChatRepositoryImpl(api, tokenManager, serverConfig, hubClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package chats.domain.model
|
package chats.domain.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class Chat(
|
data class Chat(
|
||||||
val id: String,
|
val id: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package chats.domain.model
|
package chats.domain.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class Message(
|
data class Message(
|
||||||
val id: String,
|
val id: String,
|
||||||
val chatId: String,
|
val chatId: String,
|
||||||
@@ -19,6 +22,7 @@ data class Message(
|
|||||||
val replyTo: Message? = null
|
val replyTo: Message? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class Media(
|
data class Media(
|
||||||
val id: String,
|
val id: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
|
|||||||
@@ -152,39 +152,46 @@ fun ChatDetailScreen(
|
|||||||
val items = mutableListOf<MessageListItem>()
|
val items = mutableListOf<MessageListItem>()
|
||||||
if (state.messages.isEmpty()) return@remember items
|
if (state.messages.isEmpty()) return@remember items
|
||||||
|
|
||||||
// Группируем от новых к старым (Index 0 = bottom)
|
// Группируем от старых к новым (для normal layout)
|
||||||
|
val reversedMessages = state.messages.reversed()
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < state.messages.size) {
|
while (i < reversedMessages.size) {
|
||||||
val isoDate = state.messages[i].createdAt
|
val isoDate = reversedMessages[i].createdAt
|
||||||
// Получаем дату в локальном часовом поясе (например "2024-04-15")
|
|
||||||
val localDate = viewModel.getLocalDateString(isoDate)
|
val localDate = viewModel.getLocalDateString(isoDate)
|
||||||
val dayMessages = mutableListOf<chats.domain.model.Message>()
|
val dayMessages = mutableListOf<chats.domain.model.Message>()
|
||||||
|
|
||||||
// Собираем все сообщения за этот локальный день
|
while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
|
||||||
while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) {
|
dayMessages.add(reversedMessages[i])
|
||||||
dayMessages.add(state.messages[i])
|
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем сообщения в инвертированном порядке
|
// Добавляем заголовок даты
|
||||||
|
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
|
||||||
|
|
||||||
|
// Добавляем сообщения
|
||||||
dayMessages.forEach { msg ->
|
dayMessages.forEach { msg ->
|
||||||
items.add(MessageListItem.MessageItem(msg))
|
items.add(MessageListItem.MessageItem(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем заголовок (над группой сообщений)
|
|
||||||
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
|
|
||||||
}
|
}
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Выносим allChatMedia наружу - до LazyColumn
|
||||||
|
val allChatMedia = remember(state.messages) {
|
||||||
|
state.messages.flatMap { msg -> msg.media.map { it to msg.id } }
|
||||||
|
}
|
||||||
|
|
||||||
// Отслеживаем текущую дату для плавающего заголовка
|
// Отслеживаем текущую дату для плавающего заголовка
|
||||||
val floatingDate by remember {
|
val floatingDate by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val firstIndex = listState.firstVisibleItemIndex
|
val layoutInfo = listState.layoutInfo
|
||||||
// Не показываем плашку на самом первом элементе (там и так дата)
|
val visibleItems = layoutInfo.visibleItemsInfo
|
||||||
// И показываем только если мы прокрутили хотя бы немного
|
if (visibleItems.isEmpty()) return@derivedStateOf null
|
||||||
if (firstIndex > 0 && firstIndex < listItems.size) {
|
|
||||||
val item = listItems[firstIndex]
|
// Находим первый видимый элемент
|
||||||
|
val firstVisibleItemIndex = visibleItems.first().index
|
||||||
|
if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) {
|
||||||
|
val item = listItems[firstVisibleItemIndex]
|
||||||
if (item is MessageListItem.MessageItem) {
|
if (item is MessageListItem.MessageItem) {
|
||||||
viewModel.formatDateHeader(item.message.createdAt)
|
viewModel.formatDateHeader(item.message.createdAt)
|
||||||
} else if (item is MessageListItem.DateHeader) {
|
} else if (item is MessageListItem.DateHeader) {
|
||||||
@@ -195,67 +202,77 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Показывать ли кнопку "вниз"
|
// Показывать ли кнопку "вниз"
|
||||||
// Она должна появляться если мы не в самом низу
|
|
||||||
// Проверка видимости кнопки скролла вниз
|
|
||||||
val showScrollToBottom by remember {
|
val showScrollToBottom by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
listState.firstVisibleItemIndex > 5
|
val layoutInfo = listState.layoutInfo
|
||||||
|
val visibleItems = layoutInfo.visibleItemsInfo
|
||||||
|
val totalCount = layoutInfo.totalItemsCount
|
||||||
|
if (totalCount == 0) return@derivedStateOf false
|
||||||
|
|
||||||
|
// Проверяем, видим ли мы последний элемент
|
||||||
|
val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: 0
|
||||||
|
lastVisibleIndex < totalCount - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Авто-скролл к новым сообщениям
|
// Авто-скролл к новым сообщениям - только если мы уже внизу
|
||||||
val firstMessageId = state.messages.firstOrNull()?.id
|
val lastMessageId = state.messages.lastOrNull()?.id
|
||||||
LaunchedEffect(firstMessageId) {
|
LaunchedEffect(lastMessageId) {
|
||||||
if (firstMessageId != null) {
|
if (lastMessageId != null && listItems.isNotEmpty()) {
|
||||||
// Если мы уже внизу (индекс 0) или это наше сообщение - скроллим вниз
|
val isAtBottom = listState.layoutInfo.run {
|
||||||
val isAtBottom = listState.firstVisibleItemIndex <= 1
|
val visibleItems = visibleItemsInfo
|
||||||
val isMyMessage = state.messages.firstOrNull()?.senderId == viewModel.getCurrentUserId()
|
val totalCount = totalItemsCount
|
||||||
|
if (totalCount == 0 || visibleItems.isEmpty()) return@run true
|
||||||
|
visibleItems.last().index >= totalCount - 2
|
||||||
|
}
|
||||||
|
|
||||||
if (isAtBottom || isMyMessage) {
|
if (isAtBottom) {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(listItems.size - 1)
|
||||||
// Если мы внизу - помечаем сразу как прочитанное
|
|
||||||
if (isAtBottom && !isMyMessage) {
|
|
||||||
viewModel.markAsRead()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автоматическая прокрутка и прочтение при инициализации и новых сообщениях
|
// Автоматическая прокрутка при инициализации
|
||||||
LaunchedEffect(listItems.size) {
|
LaunchedEffect(listItems.size) {
|
||||||
if (listItems.isNotEmpty()) {
|
if (listItems.isNotEmpty()) {
|
||||||
if (state.initialScrollIndex != null && state.initialScrollIndex != -1) {
|
listState.scrollToItem(listItems.size - 1)
|
||||||
// Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0
|
viewModel.onInitialScrollDone()
|
||||||
listState.scrollToItem(0)
|
viewModel.markAsRead()
|
||||||
viewModel.onInitialScrollDone()
|
|
||||||
viewModel.markAsRead()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подгрузка истории при прокрутке вверх
|
// Подгрузка истории при прокрутке вверх
|
||||||
LaunchedEffect(listState, state.messages.size) {
|
LaunchedEffect(listState) {
|
||||||
snapshotFlow {
|
snapshotFlow { listState.layoutInfo }
|
||||||
val lastIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
.collect { layoutInfo ->
|
||||||
val totalCount = listState.layoutInfo.totalItemsCount
|
val visibleItems = layoutInfo.visibleItemsInfo
|
||||||
lastIndex to totalCount
|
val totalCount = layoutInfo.totalItemsCount
|
||||||
}
|
if (totalCount == 0 || visibleItems.isEmpty()) return@collect
|
||||||
.collect { (lastVisibleIndex, totalCount) ->
|
|
||||||
if (totalCount > 0 && lastVisibleIndex >= totalCount - 20 && !state.isLoadingMore) {
|
val firstVisibleIndex = visibleItems.first().index
|
||||||
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, total $totalCount")
|
if (firstVisibleIndex < 5 && !state.isLoadingMore) {
|
||||||
viewModel.loadMoreMessages()
|
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex")
|
||||||
|
viewModel.loadMoreMessages()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val unreadCount = remember(state.messages) {
|
val unreadCount = remember(state.messages) {
|
||||||
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
|
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0)
|
// Авто-прочитка при нахождении внизу списка
|
||||||
LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) {
|
LaunchedEffect(listState.isScrollInProgress) {
|
||||||
if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) {
|
if (!listState.isScrollInProgress) {
|
||||||
viewModel.markAsRead()
|
val layoutInfo = listState.layoutInfo
|
||||||
|
val visibleItems = layoutInfo.visibleItemsInfo
|
||||||
|
val totalCount = layoutInfo.totalItemsCount
|
||||||
|
if (totalCount > 0 && visibleItems.isNotEmpty()) {
|
||||||
|
val lastVisibleIndex = visibleItems.last().index
|
||||||
|
if (lastVisibleIndex >= totalCount - 2) {
|
||||||
|
viewModel.markAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,21 +355,26 @@ fun ChatDetailScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
reverseLayout = true,
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
val allChatMedia = state.messages.flatMap { msg ->
|
items(
|
||||||
msg.media.map { it to msg.id }
|
items = listItems,
|
||||||
}.reversed()
|
key = { item ->
|
||||||
|
when(item) {
|
||||||
items(listItems, key = {
|
is MessageListItem.MessageItem -> "msg_${item.message.id}"
|
||||||
when(it) {
|
is MessageListItem.DateHeader -> "date_${item.date}"
|
||||||
is MessageListItem.MessageItem -> it.message.id
|
MessageListItem.UnreadSeparator -> "unread_separator"
|
||||||
is MessageListItem.DateHeader -> "date_${it.date}"
|
}
|
||||||
MessageListItem.UnreadSeparator -> "unread_separator"
|
},
|
||||||
|
contentType = { item ->
|
||||||
|
when(item) {
|
||||||
|
is MessageListItem.MessageItem -> "message"
|
||||||
|
is MessageListItem.DateHeader -> "date_header"
|
||||||
|
MessageListItem.UnreadSeparator -> "unread_separator"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}) { item ->
|
) { item ->
|
||||||
when(item) {
|
when(item) {
|
||||||
is MessageListItem.MessageItem -> {
|
is MessageListItem.MessageItem -> {
|
||||||
SwipeableMessageItem(
|
SwipeableMessageItem(
|
||||||
@@ -487,7 +509,7 @@ fun ChatDetailScreen(
|
|||||||
.border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape)
|
.border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape)
|
||||||
.clickable {
|
.clickable {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(listItems.size - 1)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -1032,6 +1054,7 @@ fun ForwardChatSelectionDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed class MessageListItem {
|
sealed class MessageListItem {
|
||||||
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
|
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
|
||||||
data class DateHeader(val date: String) : MessageListItem()
|
data class DateHeader(val date: String) : MessageListItem()
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ private fun EmojiContent(
|
|||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(8),
|
columns = GridCells.Fixed(8),
|
||||||
state = scrollState,
|
state = scrollState,
|
||||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
|
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
emojiCategories.forEach { category ->
|
emojiCategories.forEach { category ->
|
||||||
// Заголовок категории
|
// Заголовок категории
|
||||||
@@ -214,7 +216,7 @@ private fun EmojiContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items(category.emojis) { emoji ->
|
items(category.emojis, key = { emoji -> emoji }) { emoji ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
@@ -362,9 +364,11 @@ private fun GifContent(
|
|||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(3),
|
columns = GridCells.Fixed(3),
|
||||||
modifier = Modifier.weight(1f).padding(4.dp),
|
modifier = Modifier.weight(1f).padding(4.dp),
|
||||||
contentPadding = PaddingValues(4.dp)
|
contentPadding = PaddingValues(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(displayGifs) { gif ->
|
items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
|
||||||
val url = gif.files?.get("hd")?.get("gif")?.url
|
val url = gif.files?.get("hd")?.get("gif")?.url
|
||||||
?: gif.files?.get("sd")?.get("gif")?.url
|
?: gif.files?.get("sd")?.get("gif")?.url
|
||||||
?: gif.file?.get("hd")?.get("gif")?.url
|
?: gif.file?.get("hd")?.get("gif")?.url
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package chats.presentation.components
|
package chats.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
import androidx.compose.foundation.gestures.draggable
|
import androidx.compose.foundation.gestures.draggable
|
||||||
@@ -30,10 +29,10 @@ fun SwipeableMessageItem(
|
|||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val offsetX = remember { Animatable(0f) }
|
|
||||||
val replyThreshold = with(density) { 60.dp.toPx() }
|
val replyThreshold = with(density) { 60.dp.toPx() }
|
||||||
val maxDrag = with(density) { 90.dp.toPx() }
|
val maxDrag = with(density) { 90.dp.toPx() }
|
||||||
|
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
var isTriggered by remember { mutableStateOf(false) }
|
var isTriggered by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -42,27 +41,18 @@ fun SwipeableMessageItem(
|
|||||||
.draggable(
|
.draggable(
|
||||||
orientation = Orientation.Horizontal,
|
orientation = Orientation.Horizontal,
|
||||||
state = rememberDraggableState { delta ->
|
state = rememberDraggableState { delta ->
|
||||||
scope.launch {
|
// We only allow swiping to the left (negative delta)
|
||||||
// We only allow swiping to the left (negative delta)
|
val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
|
||||||
val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f)
|
offsetX = newOffset
|
||||||
offsetX.snapTo(newOffset)
|
|
||||||
|
|
||||||
if (newOffset <= -replyThreshold && !isTriggered) {
|
isTriggered = newOffset <= -replyThreshold
|
||||||
isTriggered = true
|
|
||||||
// Trigger haptic feedback if available?
|
|
||||||
} else if (newOffset > -replyThreshold) {
|
|
||||||
isTriggered = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
if (offsetX.value <= -replyThreshold) {
|
if (offsetX <= -replyThreshold) {
|
||||||
onReply()
|
onReply()
|
||||||
}
|
}
|
||||||
scope.launch {
|
offsetX = 0f
|
||||||
offsetX.animateTo(0f)
|
isTriggered = false
|
||||||
isTriggered = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -72,7 +62,7 @@ fun SwipeableMessageItem(
|
|||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.padding(end = 16.dp)
|
.padding(end = 16.dp)
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f))
|
.alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
|
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -88,7 +78,7 @@ fun SwipeableMessageItem(
|
|||||||
// Message Content
|
// Message Content
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
|
.offset { IntOffset(offsetX.roundToInt(), 0) }
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package core.database.data
|
|
||||||
|
|
||||||
import androidx.room.*
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
@Entity(tableName = "messages")
|
|
||||||
data class MessageEntity(
|
|
||||||
@PrimaryKey val id: String,
|
|
||||||
val chatId: String,
|
|
||||||
val senderId: String,
|
|
||||||
val senderName: String,
|
|
||||||
val senderAvatar: String?,
|
|
||||||
val content: String?,
|
|
||||||
val sequenceId: Int,
|
|
||||||
val createdAt: String,
|
|
||||||
val mediaType: String,
|
|
||||||
val mediaJson: String, // Simplified for now
|
|
||||||
val reactionsJson: String,
|
|
||||||
val isRead: Boolean,
|
|
||||||
val replyToId: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface MessageDao {
|
|
||||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
|
|
||||||
fun getMessages(chatId: String): Flow<List<MessageEntity>>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
|
||||||
|
|
||||||
@Query("DELETE FROM messages WHERE chatId = :chatId")
|
|
||||||
suspend fun clearChat(chatId: String)
|
|
||||||
|
|
||||||
@Query("DELETE FROM messages WHERE id = :messageId")
|
|
||||||
suspend fun deleteMessage(messageId: String)
|
|
||||||
|
|
||||||
@Query("UPDATE messages SET isRead = 1 WHERE chatId = :chatId AND sequenceId <= :lastReadSequenceId AND isRead = 0")
|
|
||||||
suspend fun markMessagesAsRead(chatId: String, lastReadSequenceId: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Database(entities = [MessageEntity::class], version = 1)
|
|
||||||
abstract class ChatDatabase : RoomDatabase() {
|
|
||||||
abstract fun messageDao(): MessageDao
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package core.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.Room
|
|
||||||
import core.database.data.ChatDatabase
|
|
||||||
import core.database.data.MessageDao
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object DatabaseModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDatabase(@ApplicationContext context: Context): ChatDatabase {
|
|
||||||
return Room.databaseBuilder(
|
|
||||||
context,
|
|
||||||
ChatDatabase::class.java,
|
|
||||||
"chat_database"
|
|
||||||
).fallbackToDestructiveMigration().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideMessageDao(database: ChatDatabase): MessageDao {
|
|
||||||
return database.messageDao()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-8
@@ -1,12 +1,5 @@
|
|||||||
package core.notifications.data
|
package core.notifications.data
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
import auth.data.remote.api.AuthApi
|
import auth.data.remote.api.AuthApi
|
||||||
@@ -18,7 +11,7 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ForkFirebaseMessagingService : FirebaseMessagingService() {
|
class KnotFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var authApi: AuthApi
|
lateinit var authApi: AuthApi
|
||||||
@@ -7,9 +7,10 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
private const val CHANNEL_ID = "fork_notifications_channel"
|
private const val CHANNEL_ID = "knot_notifications_channel"
|
||||||
|
|
||||||
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
|
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
|
||||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -19,7 +20,7 @@ object NotificationHelper {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Fork Messenger Notifications",
|
"Knot Notifications",
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
).apply {
|
).apply {
|
||||||
description = "Chat and system notifications"
|
description = "Chat and system notifications"
|
||||||
@@ -42,7 +43,7 @@ object NotificationHelper {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ private val DarkColors = darkColorScheme(
|
|||||||
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
|
val SoftSquareShape = RoundedCornerShape(14.dp) // "Мягкий квадрат" как в вэб
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ForkMessengerTheme(content: @Composable () -> Unit) {
|
fun KnotTheme(content: @Composable () -> Unit) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = DarkColors,
|
colorScheme = DarkColors,
|
||||||
shapes = MaterialTheme.shapes.copy(
|
shapes = MaterialTheme.shapes.copy(
|
||||||
|
|||||||
@@ -284,8 +284,8 @@ fun LazyListScope.renderFiles() {
|
|||||||
fun LazyListScope.renderLinks() {
|
fun LazyListScope.renderLinks() {
|
||||||
items(2) {
|
items(2) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("https://github.com/forkmessager") },
|
headlineContent = { Text("https://github.com/knot-messenger") },
|
||||||
supportingContent = { Text("GitHub - ForkMessager Project") },
|
supportingContent = { Text("GitHub - Knot Project") },
|
||||||
leadingContent = { Icon(Icons.Default.Link, contentDescription = null) }
|
leadingContent = { Icon(Icons.Default.Link, contentDescription = null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user