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() }