Офлайн режим, начало
This commit is contained in:
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.
@@ -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>> {
|
||||
@@ -81,12 +119,12 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user