Офлайн режим, начало

This commit is contained in:
Халимов Рустам
2026-04-20 01:00:35 +03:00
parent 7c66e1c0c0
commit 38e7184a0d
18 changed files with 214 additions and 35 deletions
Binary file not shown.
Binary file not shown.
@@ -60,4 +60,5 @@
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Reply to </string>
<string name="reply_self">yourself</string>
<string name="no_messages_yet">No messages yet</string>
</resources>
@@ -82,4 +82,5 @@
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Ответ </string>
<string name="reply_self">самому себе</string>
<string name="no_messages_yet">Сообщений пока нет</string>
</resources>
@@ -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<Chat> {
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<List<Chat>> {
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<PagingData<Message>> {
@@ -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<List<Message>> {
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)
}
+41 -1
View File
@@ -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()
)
}
+10 -2
View File
@@ -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
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow
interface ChatRepository {
suspend fun getChats(): List<Chat>
fun getChatsFlow(): Flow<List<Chat>>
// Flow для UI (простой список)
fun getMessagesFlow(chatId: String): Flow<List<Message>>
@@ -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<Chat> = 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
)
}
}
}
}
@@ -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(
@@ -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<List<ChatEntity>>
@Query("SELECT * FROM chats ORDER BY isPinned DESC, lastMessageAt DESC")
suspend fun getAllChats(): List<ChatEntity>
@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<ChatEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChat(chat: ChatEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChats(chats: List<ChatEntity>)
@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"
@@ -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())
}
}
+2 -1
View File
@@ -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()
}