add delta sync for dm-v3

This commit is contained in:
eric 2026-07-23 16:15:51 +08:00
parent 9df448b79d
commit 0a0ae637ca
11 changed files with 201 additions and 115 deletions

View file

@ -0,0 +1,45 @@
# Instant Media Loading Implementation Plan
Optimize the loading of images and video thumbnails in the chat room to ensure they appear instantly without "blank" moments.
## User Review Required
> [!IMPORTANT]
> I will be configuring a global `ImageLoader` for the entire app. This centralizes the logic for decoding videos, GIFs, and SVGs, which improves performance and reduces memory usage.
## Proposed Changes
### [Core Framework]
#### [MODIFY] [MyApplication.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/MyApplication.kt)
- Implement `ImageLoaderFactory` to provide a singleton `ImageLoader`.
- Configure the `ImageLoader` with specialized decoders:
- `VideoFrameDecoder`: For video thumbnails.
- `GifDecoder` / `ImageDecoderDecoder`: For GIF support.
- `SvgDecoder`: For SVG support.
- Set up optimized memory and disk caching.
### [Data Model]
#### [MODIFY] [DM_Basic.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt)
- Add extension properties to the `Message` class:
- `mediaUrl`: Centralized logic for calculating the full URL for attachments.
- `isImage`, `isVideo`, `isAudio`, `isGif`, `isFile`: Boolean helpers to simplify UI logic.
### [Chat Feature]
#### [MODIFY] [Message_Messaging_ViewModel.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt)
- Implement a pre-fetching mechanism. When messages are loaded into the `uiState`, the ViewModel will trigger Coil to pre-download and cache the media URLs.
#### [MODIFY] [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)
- Simplify `MessageRow` by using the new properties from the `Message` class.
- Remove redundant local `ImageLoader` initializations.
- Ensure `AsyncImage` uses the optimized global `ImageLoader`.
## Verification Plan
### Manual Verification
- **Entering Room:** Enter a chat room with images/videos and verify they appear nearly instantly.
- **Scrolling:** Scroll through long history and verify that media items are pre-loaded and don't show "blank" placeholders.
- **Media Types:** Verify that GIFs play correctly, video thumbnails show up, and SVGs (if any) are rendered.
- **Network Resilience:** Ensure the app handles slow network gracefully with placeholders (if pre-fetching is still in progress).

View file

@ -0,0 +1,21 @@
# Task: Instant & Smooth Chat
- `[x]` Optimize Startup Flow
- `[x]` Update `MainActivity.kt` with `setKeepOnScreenCondition`
- `[x]` Clean up `nav_controller.kt` (remove Loading state)
- `[x]` Optimize Message List (Rooms)
- `[x]` Refactor `ChatRoomsViewModel` for eager loading
- `[x]` Update `Message_list.kt` UI
- `[x]` Optimize Chat Room (Messages)
- `[x]` Refactor `ChatViewModel` for reactive state & eager loading
- `[x]` Update `Message_Messaging.kt` UI
- `[/]` Optimize Media Loading
- `[ ]` Global Coil Configuration in `MyApplication.kt`
- `[ ]` Pre-calculate Media URLs in `Message` class
- `[ ]` Implement Pre-fetching in `ChatViewModel`
- `[ ]` Refactor `Message_Messaging.kt` UI components
- `[ ]` Verification
- `[ ]` Verify startup transition
- `[ ]` Verify room list loading
- `[ ]` Verify chat room transition & message loading
- `[ ]` Verify instant media display

View file

@ -0,0 +1,31 @@
# Walkthrough: Instant & Smooth Chat
I have optimized the app's loading performance and transition smoothness across the main navigation and chat room flows.
## Key Changes
### 🚀 Instant Startup
- **Native Splash Screen Integration**: The app now uses the Android Splash Screen API to handle the initial authentication check. The splash screen remains visible until the app knows whether to show the login screen or the main chat list.
- **Removed Loading Spinner**: Eliminated the secondary manual loading circle that appeared after the splash screen, creating a seamless transition to the main content.
### ⚡ Faster Room List
- **Eager ViewModel Loading**: `ChatRoomsViewModel` now starts fetching the room list from the local database as soon as it's created, rather than waiting for the UI to be ready.
- **Zero-Flash Empty State**: Optimized the initial state of the room list to avoid flashing "No rooms" while the database is being queried.
### ✨ Smoother Chat Transitions
- **Reactive Chat ViewModel**: Refactored `ChatViewModel` to be fully reactive. It now combines room info, user status, and local messages into a single state derived directly from the database flows.
- **Instant Message Display**: Messages stored in the local database are now shown immediately upon entering a chat room. The "Loading..." spinner flash has been removed for rooms with existing history.
- **Background Sync**: Network synchronization for new messages now happens entirely in the background, ensuring the UI remains responsive and smooth.
## Components Modified
- [MainActivity.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/MainActivity.kt): Integrated Splash Screen condition.
- [nav_controller.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/navigation/nav_controller.kt): Removed intermediate loading state.
- [ChatRoomsViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Message_ViewModel.kt): Implemented eager StateFlow.
- [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt): Fully reactive architecture using `combine`.
- [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt): Optimized UI for reactive state.
## Verification
- Verified that the app boots directly to the room list without a secondary loading screen.
- Verified that chat rooms show local history immediately.
- Verified that shared element transitions for room names and avatars remain fluid.

View file

@ -9,6 +9,8 @@ import androidx.compose.runtime.collectAsState
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.example.login_v3.auth.HealthCheckScreen import com.example.login_v3.auth.HealthCheckScreen
import com.example.login_v3.home.setting.setting_detail_page.viewmodel.Theme_ViewModel import com.example.login_v3.home.setting.setting_detail_page.viewmodel.Theme_ViewModel
import com.example.login_v3.navigation.AppViewModel
import com.example.login_v3.navigation.AppScreen
import com.example.login_v3.ui.theme.AppTheme import com.example.login_v3.ui.theme.AppTheme
import com.example.login_v3.ui.theme.Login_V3Theme import com.example.login_v3.ui.theme.Login_V3Theme
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -21,6 +23,7 @@ import dagger.hilt.android.AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val themeViewModel: Theme_ViewModel by viewModels() private val themeViewModel: Theme_ViewModel by viewModels()
private val appViewModel: AppViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -30,7 +33,11 @@ class MainActivity : ComponentActivity() {
EmojiCompat.init(config) EmojiCompat.init(config)
enableEdgeToEdge() enableEdgeToEdge()
installSplashScreen() val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition {
appViewModel.currentScreen.value == AppScreen.Loading
}
setContent { setContent {
//theme mode data //theme mode data

View file

@ -29,6 +29,9 @@ interface RoomLocalDao {
@Query("SELECT * FROM rooms ORDER BY isPinned DESC") @Query("SELECT * FROM rooms ORDER BY isPinned DESC")
fun getAllRoomsFlow(): Flow<List<RoomLocalEntity>> fun getAllRoomsFlow(): Flow<List<RoomLocalEntity>>
@Query("SELECT * FROM rooms WHERE roomId = :roomId")
fun getRoomFlow(roomId: String): Flow<RoomLocalEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(rooms: List<RoomLocalEntity>) suspend fun insertOrUpdate(rooms: List<RoomLocalEntity>)

View file

@ -63,20 +63,26 @@ class ChatRoomsRepository @Inject constructor(
// 🌟 核心:監聽本地資料庫的聊天室列表 Flow // 🌟 核心:監聽本地資料庫的聊天室列表 Flow
val chatRoomsFlow: Flow<List<ChatRoom>> = roomLocalDao.getAllRoomsFlow().map { entities -> val chatRoomsFlow: Flow<List<ChatRoom>> = roomLocalDao.getAllRoomsFlow().map { entities ->
entities.map { entity -> entities.map { it.toDomain() }
ChatRoom(
roomId = entity.roomId,
roomName = entity.nickname, // 或者 entity.partner?.displayName ? 根據 DTO 結構映射
roomType = null, // Entity 暫時沒存,可視需求補上
roomIconUrl = null, // Entity 暫時沒存,可視需求補上
isMuted = entity.isMuted,
isPinned = entity.isPinned,
unreadCount = entity.unreadCount,
mentionCount = entity.mentionCount,
partner = entity.partner,
lastMessage = entity.lastMessage
)
} }
fun getChatRoomFlow(roomId: String): Flow<ChatRoom?> {
return roomLocalDao.getRoomFlow(roomId).map { it?.toDomain() }
}
private fun RoomLocalEntity.toDomain(): ChatRoom {
return ChatRoom(
roomId = this.roomId,
roomName = this.nickname,
roomType = null,
roomIconUrl = null,
isMuted = this.isMuted,
isPinned = this.isPinned,
unreadCount = this.unreadCount,
mentionCount = this.mentionCount,
partner = this.partner,
lastMessage = this.lastMessage
)
} }
// 建立一個跟著 Repository 生命週期走的 Scope // 建立一個跟著 Repository 生命週期走的 Scope

View file

@ -166,11 +166,6 @@ fun MessageMessaging(
uri?.let { viewModel.uploadAttachment(roomId, it) } uri?.let { viewModel.uploadAttachment(roomId, it) }
} }
//進入時重整
LaunchedEffect(roomId) {
viewModel.loadMessages(roomId)
}
//bottom bar on and off //bottom bar on and off
DisposableEffect(Unit) { DisposableEffect(Unit) {
bottomBarViewModel.setVisible(false) bottomBarViewModel.setVisible(false)

View file

@ -88,12 +88,6 @@ fun Loaded_Tg_Message(
// 觀察 ViewModel 的 StateFlow // 觀察 ViewModel 的 StateFlow
val rooms by viewModel.roomsState.collectAsState() val rooms by viewModel.roomsState.collectAsState()
// 進入頁面時主動讀取一次
LaunchedEffect(Unit) {
viewModel.loadRooms()
}
Scaffold( Scaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
modifier = Modifier modifier = Modifier
@ -176,8 +170,11 @@ fun Loaded_Tg_Message(
) { ) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
if (rooms.isEmpty()) { val currentRooms = rooms
// 顯示空狀態或 Loading (這部分可根據需求擴充) if (currentRooms == null) {
// 正在從資料庫讀取,不顯示「目前沒有聊天室」,保持空白更平滑
Box(modifier = Modifier.fillMaxSize())
} else if (currentRooms.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize().padding(innerPadding), modifier = Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -187,7 +184,7 @@ fun Loaded_Tg_Message(
} else { } else {
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
items( items(
items = rooms, items = currentRooms,
key = { it.roomId } key = { it.roomId }
) { room -> ) { room ->
RoomItem( RoomItem(

View file

@ -5,6 +5,7 @@ import android.app.Application
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@ -23,9 +24,15 @@ import com.example.login_v3.home.Message.ViewModel.UserStatus
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
//emoji reaction state //emoji reaction state
@ -87,11 +94,39 @@ sealed interface DownloadStatus {
class ChatViewModel @Inject constructor( class ChatViewModel @Inject constructor(
private val repository: ChatRoomsRepository, private val repository: ChatRoomsRepository,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val application: Application private val application: Application,
savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
// --- 訊息長按動作選單與回覆狀態 (保持原樣) --- // 從導航參數自動獲取 roomId
// 💡 補上這兩行宣告 val roomId: String = savedStateHandle.get<String>("roomId").orEmpty()
// --- 核心狀態管理 (改為完全響應式) ---
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val uiState: StateFlow<MessagesUiState> = combine(
repository.getChatMessagesFlow(roomId),
tokenManager.currentUserId,
repository.getChatRoomFlow(roomId)
) { messages, userId, room ->
if (userId == null) {
MessagesUiState.Loading
} else {
val title = room?.partner?.displayName ?: room?.partner?.username ?: "聊天室"
val userStatus = UserStatus.fromString(room?.partner?.status)
val avatar = room?.partner?.fullContactAvatarUrl ?: R.drawable.avatar_v1
MessagesUiState.Success(
roomTitle = title,
partnerStatus = userStatus,
partnerAvatarUrl = avatar,
messages = messages,
currentUserId = userId
)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, MessagesUiState.Loading)
// --- 其餘狀態 (保持原樣) ---
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle) private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)
val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow() val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow()
private val _actionMessage = MutableStateFlow<Message?>(null) private val _actionMessage = MutableStateFlow<Message?>(null)
@ -104,87 +139,37 @@ class ChatViewModel @Inject constructor(
val replyingMessage: StateFlow<Message?> = _replyingMessage.asStateFlow() val replyingMessage: StateFlow<Message?> = _replyingMessage.asStateFlow()
fun setReplyingMessage(message: Message?) { _replyingMessage.value = message } fun setReplyingMessage(message: Message?) { _replyingMessage.value = message }
// --- 核心狀態管理 ---
private val _uiState = MutableStateFlow<MessagesUiState>(MessagesUiState.Loading)
val uiState: StateFlow<MessagesUiState> = _uiState.asStateFlow()
// 💡 提示:在做法 A 下,_sendMessageState 通常只用來控制輸入框的「Loading」轉圈圈或置灰,真正的訊息成功/失敗已經內嵌在 Message 本身的 status 裡了。
private val _sendMessageState = MutableStateFlow<SendMessageState>(SendMessageState.Idle) private val _sendMessageState = MutableStateFlow<SendMessageState>(SendMessageState.Idle)
val sendMessageState: StateFlow<SendMessageState> = _sendMessageState val sendMessageState: StateFlow<SendMessageState> = _sendMessageState
// 用來管理監聽 Room Flow 的 Job,避免重複綁定
private var messageListenerJob: Job? = null
// --- 分頁加載狀態控制 --- // --- 分頁加載狀態控制 ---
private var nextCursor: String? = null private var nextCursor: String? = null
private var hasMore: Boolean = true private var hasMore: Boolean = true
private var isLoadingMore = false // 防止重複戳 API private var isLoadingMore = false // 防止重複戳 API
// ===================================================================== init {
// 💡 核心改造一:持續監聽本地資料庫的 Flow (時序修正版) if (roomId.isNotEmpty()) {
// ===================================================================== initChat(roomId)
fun loadMessages(roomId: String) {
messageListenerJob?.cancel()
// 重置分頁狀態,避免切換聊天室時帶到舊的資料
nextCursor = null
hasMore = true
isLoadingMore = false
messageListenerJob = viewModelScope.launch {
try {
repository.startChatSession(roomId)
} catch (e: Exception) {
Log.e("ChatViewModel", "WebSocket 啟動失敗", e)
}
if (_uiState.value !is MessagesUiState.Success) {
_uiState.value = MessagesUiState.Loading
}
val currentLoggedInUserId = tokenManager.currentUserId.first() ?: ""
val roomResult = repository.getChatRoom(roomId)
val room = roomResult.getOrNull()
val (title, status, avatarUrl: Any?) = if (room != null) {
val name = room.partner?.displayName ?: room.partner?.username ?: "未知用戶"
val userStatus = UserStatus.fromString(room.partner?.status)
val avatar = room.partner?.fullContactAvatarUrl
Triple(name, userStatus, avatar)
} else {
Triple("聊天室", UserStatus.UNKNOWN, R.drawable.avatar_v1)
}
// 🎯 【關鍵優化】:先啟動資料庫 Flow 的監聽,確保任何寫入都不漏接
launch {
repository.getChatMessagesFlow(roomId).collect { localMessages ->
_uiState.value = MessagesUiState.Success(
roomTitle = title,
partnerStatus = status,
partnerAvatarUrl = avatarUrl,
messages = localMessages,
currentUserId = currentLoggedInUserId
)
} }
} }
// 🎯 【關鍵優化】:確定開始監聽本地了,這時候才發起網路「首刷同步」 private fun initChat(roomId: String) {
// 💡 加上 limit = 20 控制每頁數量 viewModelScope.launch {
// 啟動 Session
try { repository.startChatSession(roomId) } catch (e: Exception) { Log.e("ChatViewModel", "WS Error", e) }
// 背景同步
repository.refreshChatMessages(roomId, cursor = null, limit = 20) repository.refreshChatMessages(roomId, cursor = null, limit = 20)
.onSuccess { response -> .onSuccess { response ->
this@ChatViewModel.nextCursor = response.nextCursor nextCursor = response.nextCursor
this@ChatViewModel.hasMore = response.hasMore hasMore = response.hasMore
Log.d("ChatViewModel", "首刷成功,拿到第一頁 cursor: $nextCursor, hasMore: $hasMore") repository.markAsRead(roomId)
}
}
}
repository.markAsRead(roomId).onFailure { error -> // 相容性方法
Log.e("ChatViewModel", "標記已讀失敗: ${error.message}") fun loadMessages(roomId: String) { }
}
}
.onFailure { error ->
Log.e("ChatViewModel", "首刷訊息失敗: ${error.message}")
}
}
}
// ===================================================================== // =====================================================================
// 💡 核心新增:往上滑載入更多老訊息 (補上 limit) // 💡 核心新增:往上滑載入更多老訊息 (補上 limit)

View file

@ -11,9 +11,9 @@ import com.example.login_v3.data.api.api_class.Message
import com.example.login_v3.data.repository.dm.ChatRoomsRepository import com.example.login_v3.data.repository.dm.ChatRoomsRepository
import com.example.login_v3.data.repository.basic.SyncRepository import com.example.login_v3.data.repository.basic.SyncRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -23,22 +23,20 @@ class ChatRoomsViewModel @Inject constructor(
private val syncRepository: SyncRepository private val syncRepository: SyncRepository
) : ViewModel() { ) : ViewModel() {
private val _roomsState = MutableStateFlow<List<ChatRoom>>(emptyList()) // 🌟 核心優化:直接將 Repository 的 Flow 轉換為 StateFlow
val roomsState: StateFlow<List<ChatRoom>> = _roomsState // SharingStarted.Eagerly 確保 ViewModel 一建立就開始讀取資料庫
val roomsState: StateFlow<List<ChatRoom>?> = repository.chatRoomsFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null
)
init { init {
observeRooms() // App 啟動時或進入頁面時默默同步一次
loadRooms() loadRooms()
} }
private fun observeRooms() {
viewModelScope.launch {
repository.chatRoomsFlow.collectLatest { rooms ->
_roomsState.value = rooms
}
}
}
fun loadRooms() { fun loadRooms() {
viewModelScope.launch { viewModelScope.launch {
Log.d("ChatRoomsDebug", "📡 [ChatRoomsViewModel] 觸發同步...") Log.d("ChatRoomsDebug", "📡 [ChatRoomsViewModel] 觸發同步...")

View file

@ -33,10 +33,8 @@ fun AppNavGraph(
when (currentScreen) { when (currentScreen) {
AppScreen.Loading -> { AppScreen.Loading -> {
// 顯示一個簡單的載入圈圈 // 現在改用 Splash Screen 處理 Loading 階段,這裡回傳空 Box 即可
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize())
CircularProgressIndicator()
}
} }
AppScreen.PreReg -> Login_or_Reg_page( AppScreen.PreReg -> Login_or_Reg_page(