diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin
index a5f9a64..c9d323c 100644
Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ
diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock
index 10b3807..0da0247 100644
Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin
index ba1e02d..0aeff01 100644
Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock
index 026b341..e0aedcd 100644
Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin
index 36d973d..10e0b3b 100644
Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ
diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock
index fda3afe..3fdb405 100644
Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin
index 8b1b0a1..b6e3c72 100644
Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ
diff --git a/client-mobile/app/src/main/res/values-en/strings.xml b/client-mobile/app/src/main/res/values-en/strings.xml
index 8654653..a584647 100644
--- a/client-mobile/app/src/main/res/values-en/strings.xml
+++ b/client-mobile/app/src/main/res/values-en/strings.xml
@@ -60,4 +60,5 @@
GIF
Reply to
yourself
+ No messages yet
diff --git a/client-mobile/app/src/main/res/values/strings.xml b/client-mobile/app/src/main/res/values/strings.xml
index b3a476a..d0c3e7e 100644
--- a/client-mobile/app/src/main/res/values/strings.xml
+++ b/client-mobile/app/src/main/res/values/strings.xml
@@ -82,4 +82,5 @@
GIF
Ответ
самому себе
+ Сообщений пока нет
diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
index 4dc3509..9c56127 100644
--- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
+++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
@@ -15,6 +15,7 @@ import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository
import core.database.data.ChatDatabase
+import core.database.data.ChatDao
import core.database.data.MessageDao
import core.database.data.MessageEntity
import core.database.data.SyncStatus
@@ -51,7 +52,8 @@ class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig,
- private val dao: MessageDao,
+ private val messageDao: MessageDao,
+ private val chatDao: ChatDao,
private val database: ChatDatabase,
private val hubClient: ChatHubClient,
private val signalRHandler: MessageSignalRHandler,
@@ -68,7 +70,43 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun getChats(): List {
val currentUserId = tokenManager.getUserId() ?: ""
- return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
+ return try {
+ // Пробуем загрузить из сети
+ val chats = api.getChats().map { it.toDomain(currentUserId, baseUrl) }
+ // Кэшируем в Room
+ val entities = chats.map { it.toEntity() }
+ chatDao.insertChats(entities)
+ // Кэшируем последние сообщения
+ chats.forEach { chat ->
+ chat.lastMessage?.let { msg ->
+ messageDao.upsertMessage(msg.toEntity(gson))
+ }
+ }
+ android.util.Log.d(TAG, "Cached ${entities.size} chats with messages")
+ chats
+ } catch (e: Exception) {
+ android.util.Log.d(TAG, "Network load failed, using cache")
+ // При ошибке - возвращаем из кэша с загрузкой последних сообщений
+ chatDao.getAllChats().map { entity ->
+ val lastMessage = entity.lastMessageId?.let { messageId ->
+ messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
+ }
+ entity.toDomain(currentUserId, baseUrl, lastMessage)
+ }
+ }
+ }
+
+ override fun getChatsFlow(): Flow> {
+ val currentUserId = tokenManager.getUserId() ?: ""
+ return chatDao.getAllChatsFlow().map { entities ->
+ entities.map { entity ->
+ // Загружаем последнее сообщение из базы для каждого чата
+ val lastMessage = entity.lastMessageId?.let { messageId ->
+ messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
+ }
+ entity.toDomain(currentUserId, baseUrl, lastMessage)
+ }
+ }
}
override fun getMessagesPaging(chatId: String): Flow> {
@@ -78,15 +116,15 @@ class ChatRepositoryImpl @Inject constructor(
initialLoadSize = 50,
enablePlaceholders = false
)
-
+
return Pager(
config = pagingConfig,
- pagingSourceFactory = { dao.getMessagesPagingSource(chatId) },
+ pagingSourceFactory = { messageDao.getMessagesPagingSource(chatId) },
remoteMediator = MessageRemoteMediator(
chatId = chatId,
api = api,
database = database,
- dao = dao,
+ dao = messageDao,
serverConfig = serverConfig,
tokenManager = tokenManager
)
@@ -96,7 +134,7 @@ class ChatRepositoryImpl @Inject constructor(
}
override fun getMessagesFlow(chatId: String): Flow> {
- return dao.getMessages(chatId).map { entities ->
+ return messageDao.getMessages(chatId).map { entities ->
entities.map { it.toDomain(baseUrl, gson) }
}
}
@@ -111,7 +149,7 @@ class ChatRepositoryImpl @Inject constructor(
if (messages.isNotEmpty()) {
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
- dao.upsertMessages(entities)
+ messageDao.upsertMessages(entities)
Log.d(TAG, "Cached ${entities.size} messages")
}
@@ -145,7 +183,7 @@ class ChatRepositoryImpl @Inject constructor(
editedContent = null, lastUpdated = currentTime
)
- dao.insertMessage(localMessage)
+ messageDao.insertMessage(localMessage)
Log.d(TAG, "Saved local message: $localId")
MessageSyncWorker.scheduleSync(context)
@@ -191,20 +229,20 @@ class ChatRepositoryImpl @Inject constructor(
} catch (e: Exception) {
Log.e(TAG, "Error marking messages as read", e)
}
- dao.markMessagesAsRead(chatId, lastReadSequenceId)
+ messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
}
override suspend fun saveMessage(message: Message) {
- dao.insertMessage(message.toEntity(gson))
+ messageDao.insertMessage(message.toEntity(gson))
}
override suspend fun deleteLocalMessage(messageId: String) {
- dao.markAsDeletedLocally(messageId)
+ messageDao.markAsDeletedLocally(messageId)
MessageSyncWorker.scheduleSync(context)
}
override suspend fun editLocalMessage(messageId: String, newContent: String) {
- dao.markAsEditedLocally(messageId, newContent)
+ messageDao.markAsEditedLocally(messageId, newContent)
MessageSyncWorker.scheduleSync(context)
}
@@ -239,14 +277,14 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone)
- dao.deleteMessage(messageId)
+ messageDao.deleteMessage(messageId)
}
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val response = api.editMessage(messageId, request)
- dao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
+ messageDao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
return response.toDomain(currentUserId, baseUrl)
}
diff --git a/client-mobile/chats/data/repository/Mappers.kt b/client-mobile/chats/data/repository/Mappers.kt
index cd68a02..5a4ce62 100644
--- a/client-mobile/chats/data/repository/Mappers.kt
+++ b/client-mobile/chats/data/repository/Mappers.kt
@@ -2,6 +2,9 @@ package chats.data.repository
import chats.data.remote.dto.*
import chats.domain.model.*
+import core.database.data.ChatEntity
+import core.database.data.MessageEntity
+import core.database.data.SyncStatus
// Mappers
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
@@ -23,6 +26,38 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
)
}
+fun Chat.toEntity(): ChatEntity {
+ return ChatEntity(
+ id = id,
+ name = name,
+ avatar = avatar,
+ type = type,
+ lastMessageId = lastMessage?.id,
+ lastMessageText = lastMessage?.content,
+ lastMessageAt = lastMessage?.createdAt?.let {
+ try { java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).parse(it)?.time ?: 0L }
+ catch (e: Exception) { 0L }
+ } ?: 0L,
+ unreadCount = unreadCount,
+ isPinned = false
+ )
+}
+
+fun ChatEntity.toDomain(
+ currentUserId: String,
+ baseUrl: String,
+ lastMessage: Message? = null
+): Chat {
+ return Chat(
+ id = id,
+ type = type,
+ name = name,
+ avatar = avatar,
+ unreadCount = unreadCount,
+ lastMessage = lastMessage
+ )
+}
+
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
return core.database.data.MessageEntity(
id = id,
@@ -37,7 +72,12 @@ fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEnti
mediaJson = gson.toJson(media),
reactionsJson = gson.toJson(reactions),
isRead = isRead,
- replyToId = replyTo?.id
+ replyToId = replyTo?.id,
+ syncStatus = SyncStatus.SYNCED,
+ isDeletedLocally = false,
+ isEditedLocally = false,
+ editedContent = null,
+ lastUpdated = System.currentTimeMillis()
)
}
diff --git a/client-mobile/chats/di/ChatModule.kt b/client-mobile/chats/di/ChatModule.kt
index f5cb845..25e6cb7 100644
--- a/client-mobile/chats/di/ChatModule.kt
+++ b/client-mobile/chats/di/ChatModule.kt
@@ -7,6 +7,7 @@ import chats.data.repository.ChatRepositoryImpl
import chats.data.signalr.MessageSignalRHandler
import chats.domain.repository.ChatRepository
import core.database.data.ChatDatabase
+import core.database.data.ChatDao
import core.database.data.MessageDao
import core.network.ServerConfig
import core.security.TokenManager
@@ -39,19 +40,26 @@ object ChatModule {
return MessageSignalRHandler(hubClient, dao, serverConfig, tokenManager)
}
+ @Provides
+ @Singleton
+ fun provideChatDao(database: ChatDatabase): ChatDao {
+ return database.chatDao()
+ }
+
@Provides
@Singleton
fun provideChatRepository(
api: ChatApi,
tokenManager: TokenManager,
serverConfig: ServerConfig,
- dao: MessageDao,
+ messageDao: MessageDao,
+ chatDao: ChatDao,
database: ChatDatabase,
hubClient: ChatHubClient,
signalRHandler: MessageSignalRHandler,
@ApplicationContext context: Context
): ChatRepository {
- return ChatRepositoryImpl(api, tokenManager, serverConfig, dao, database, hubClient, signalRHandler, context)
+ return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, chatDao, database, hubClient, signalRHandler, context)
}
@Provides
diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt
index f46e4e6..e32900d 100644
--- a/client-mobile/chats/domain/repository/ChatRepository.kt
+++ b/client-mobile/chats/domain/repository/ChatRepository.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow
interface ChatRepository {
suspend fun getChats(): List
+ fun getChatsFlow(): Flow>
// Flow для UI (простой список)
fun getMessagesFlow(chatId: String): Flow>
diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt
index 44fae4c..e6c68cd 100644
--- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt
+++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt
@@ -14,6 +14,8 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import chats.data.repository.toDomain
+private const val TAG = "ChatListViewModel"
+
data class ChatListState(
val chats: List = emptyList(),
val isLoading: Boolean = false,
@@ -77,11 +79,26 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
- val chats = repository.getChats()
- _state.update { it.copy(chats = sortChats(chats), isLoading = false) }
+ // Пробуем загрузить из сети (это закэширует в Room)
+ repository.getChats()
} catch (e: Exception) {
- _state.update { it.copy(isLoading = false, error = e.message) }
+ android.util.Log.d(TAG, "Initial load failed, will use cache")
}
+
+ // Подписываемся на Flow из Room (всегда работает, даже оффлайн)
+ repository.getChatsFlow()
+ .catch { e ->
+ android.util.Log.e(TAG, "Flow error", e)
+ emit(emptyList())
+ }
+ .collect { chats ->
+ _state.update {
+ it.copy(
+ chats = sortChats(chats),
+ isLoading = false
+ )
+ }
+ }
}
}
diff --git a/client-mobile/chats/presentation/components/ChatItem.kt b/client-mobile/chats/presentation/components/ChatItem.kt
index 1bfdaee..96d16b9 100644
--- a/client-mobile/chats/presentation/components/ChatItem.kt
+++ b/client-mobile/chats/presentation/components/ChatItem.kt
@@ -4,24 +4,21 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
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.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import chats.domain.model.Chat
-
-import androidx.compose.foundation.shape.RoundedCornerShape
-import core.presentation.components.AppAvatar
-import chats.domain.model.MediaType
import androidx.compose.runtime.remember
+import chats.domain.model.Chat
+import chats.domain.model.MediaType
+import core.presentation.components.AppAvatar
+import ru.knot.messager.R
@Composable
fun ChatItem(
@@ -74,14 +71,15 @@ fun ChatItem(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
+ val context = LocalContext.current
val previewText = remember(chat.lastMessage) {
val msg = chat.lastMessage
- if (msg == null) return@remember "No messages yet"
+ if (msg == null) return@remember context.getString(R.string.no_messages_yet)
if (!msg.content.isNullOrBlank()) {
msg.content
} else if (msg.mediaType == MediaType.AUDIO) {
- "Голосовое сообщение"
+ context.getString(R.string.voice_message)
} else if (msg.media.isNotEmpty()) {
when (msg.mediaType) {
MediaType.IMAGE -> "Фото"
@@ -90,7 +88,7 @@ fun ChatItem(
else -> "Медиа"
}
} else {
- "Сообщение"
+ context.getString(R.string.message)
}
}
Text(
diff --git a/client-mobile/core/database/data/ChatDatabase.kt b/client-mobile/core/database/data/ChatDatabase.kt
index bb26455..94fe00f 100644
--- a/client-mobile/core/database/data/ChatDatabase.kt
+++ b/client-mobile/core/database/data/ChatDatabase.kt
@@ -13,6 +13,23 @@ enum class SyncStatus {
FAILED // Ошибка синхронизации
}
+/**
+ * Entity для хранения чатов в локальной базе данных
+ */
+@Entity(tableName = "chats")
+data class ChatEntity(
+ @PrimaryKey val id: String,
+ val name: String,
+ val avatar: String?,
+ val type: String = "personal", // personal, group, saved
+ val lastMessageId: String? = null,
+ val lastMessageText: String? = null,
+ val lastMessageAt: Long = 0,
+ val unreadCount: Int = 0,
+ val isPinned: Boolean = false,
+ val lastUpdated: Long = System.currentTimeMillis()
+)
+
/**
* Entity для хранения сообщений в локальной базе данных
* Поддерживает офлайн-работу и фоновую синхронизацию
@@ -187,10 +204,44 @@ interface MessageDao {
suspend fun exists(id: String): Boolean
}
-@Database(entities = [MessageEntity::class], version = 2)
+@Dao
+interface ChatDao {
+ @Query("SELECT * FROM chats ORDER BY isPinned DESC, lastMessageAt DESC")
+ fun getAllChatsFlow(): Flow>
+
+ @Query("SELECT * FROM chats ORDER BY isPinned DESC, lastMessageAt DESC")
+ suspend fun getAllChats(): List
+
+ @Query("SELECT * FROM chats WHERE id = :chatId LIMIT 1")
+ suspend fun getChatById(chatId: String): ChatEntity?
+
+ @Query("SELECT * FROM chats WHERE id = :chatId LIMIT 1")
+ fun getChatByIdFlow(chatId: String): Flow
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertChat(chat: ChatEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertChats(chats: List)
+
+ @Query("DELETE FROM chats WHERE id = :chatId")
+ suspend fun deleteChat(chatId: String)
+
+ @Query("DELETE FROM chats")
+ suspend fun clearAll()
+
+ @Query("UPDATE chats SET unreadCount = :count WHERE id = :chatId")
+ suspend fun updateUnreadCount(chatId: String, count: Int)
+
+ @Query("UPDATE chats SET lastMessageId = :lastMessageId, lastMessageText = :lastMessageText, lastMessageAt = :lastMessageAt WHERE id = :chatId")
+ suspend fun updateLastMessage(chatId: String, lastMessageId: String?, lastMessageText: String?, lastMessageAt: Long)
+}
+
+@Database(entities = [MessageEntity::class, ChatEntity::class], version = 3)
@TypeConverters(SyncStatusConverter::class)
abstract class ChatDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
+ abstract fun chatDao(): ChatDao
companion object {
const val DATABASE_NAME = "knot_chat_database"
diff --git a/client-mobile/core/database/data/Migrations.kt b/client-mobile/core/database/data/Migrations.kt
index 8e33070..1002e15 100644
--- a/client-mobile/core/database/data/Migrations.kt
+++ b/client-mobile/core/database/data/Migrations.kt
@@ -37,3 +37,26 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
""".trimIndent())
}
}
+
+/**
+ * Миграция с версии 2 на версию 3
+ * Добавляет таблицу chats для кэширования списка чатов
+ */
+val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL("""
+ CREATE TABLE IF NOT EXISTS chats (
+ id TEXT PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+ avatar TEXT,
+ type TEXT NOT NULL DEFAULT 'personal',
+ lastMessageId TEXT,
+ lastMessageText TEXT,
+ lastMessageAt INTEGER NOT NULL DEFAULT 0,
+ unreadCount INTEGER NOT NULL DEFAULT 0,
+ isPinned INTEGER NOT NULL DEFAULT 0,
+ lastUpdated INTEGER NOT NULL DEFAULT 0
+ )
+ """.trimIndent())
+ }
+}
diff --git a/client-mobile/core/di/DatabaseModule.kt b/client-mobile/core/di/DatabaseModule.kt
index 4ee1350..8246cb1 100644
--- a/client-mobile/core/di/DatabaseModule.kt
+++ b/client-mobile/core/di/DatabaseModule.kt
@@ -5,6 +5,7 @@ import androidx.room.Room
import core.database.data.ChatDatabase
import core.database.data.MessageDao
import core.database.data.MIGRATION_1_2
+import core.database.data.MIGRATION_2_3
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -24,7 +25,7 @@ object DatabaseModule {
ChatDatabase::class.java,
ChatDatabase.DATABASE_NAME
)
- .addMigrations(MIGRATION_1_2)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
}