feat(chat): 添加聊天界面和本地缓存功能
- 新增 ChatScreen 和 ChatViewModel 实现聊天界面 - 新增 ConversationScreen 和 ConversationViewModel 实现会话列表 - 添加 LocalImCache 类实现本地消息和会话缓存 - 集成 IM SDK 的消息发送、接收和历史记录功能 - 实现消息搜索和分页加载功能 - 添加会话置顶、免打扰等管理功能 - 在 AppDependencies 中注册本地缓存依赖
这个提交包含在:
父节点
64fefd1bbb
当前提交
efe2a32a00
@ -0,0 +1,160 @@
|
|||||||
|
package com.xuqm.sdk.sample.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.xuqm.sdk.im.model.ConversationData
|
||||||
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class LocalImCache(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun loadConversations(): List<ConversationData> = readConversationList(KEY_CONVERSATIONS)
|
||||||
|
|
||||||
|
fun saveConversations(conversations: List<ConversationData>) {
|
||||||
|
prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(conversations)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadHistory(targetId: String, chatType: String): List<ImMessage> =
|
||||||
|
readMessageList(historyKey(targetId, chatType))
|
||||||
|
|
||||||
|
fun saveHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
|
||||||
|
prefs.edit().putString(historyKey(targetId, chatType), serializeMessageList(messages)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getHistoryPage(targetId: String, chatType: String, page: Int, size: Int): List<ImMessage> {
|
||||||
|
val history = loadHistory(targetId, chatType).sortedByDescending { it.createdAt }
|
||||||
|
val fromIndex = page * size
|
||||||
|
if (fromIndex >= history.size) return emptyList()
|
||||||
|
return history.drop(fromIndex).take(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchHistory(
|
||||||
|
targetId: String,
|
||||||
|
chatType: String,
|
||||||
|
keyword: String,
|
||||||
|
): List<ImMessage> {
|
||||||
|
val normalized = keyword.trim()
|
||||||
|
if (normalized.isBlank()) return emptyList()
|
||||||
|
return loadHistory(targetId, chatType)
|
||||||
|
.filter { it.matches(normalized) }
|
||||||
|
.sortedByDescending { it.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mergeHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
|
||||||
|
val merged = (loadHistory(targetId, chatType) + messages)
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.sortedByDescending { it.createdAt }
|
||||||
|
saveHistory(targetId, chatType, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readConversationList(key: String): List<ConversationData> {
|
||||||
|
val raw = prefs.getString(key, null) ?: return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
val array = JSONArray(raw)
|
||||||
|
buildList {
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val obj = array.getJSONObject(i)
|
||||||
|
add(
|
||||||
|
ConversationData(
|
||||||
|
targetId = obj.optString("targetId"),
|
||||||
|
chatType = obj.optString("chatType"),
|
||||||
|
lastMsgContent = obj.optString("lastMsgContent").takeIf { it.isNotBlank() },
|
||||||
|
lastMsgType = obj.optString("lastMsgType").takeIf { it.isNotBlank() },
|
||||||
|
lastMsgTime = obj.optLong("lastMsgTime"),
|
||||||
|
unreadCount = obj.optInt("unreadCount"),
|
||||||
|
isMuted = obj.optBoolean("isMuted"),
|
||||||
|
isPinned = obj.optBoolean("isPinned"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readMessageList(key: String): List<ImMessage> {
|
||||||
|
val raw = prefs.getString(key, null) ?: return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
val array = JSONArray(raw)
|
||||||
|
buildList {
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val obj = array.getJSONObject(i)
|
||||||
|
add(
|
||||||
|
ImMessage(
|
||||||
|
id = obj.optString("id"),
|
||||||
|
appId = obj.optString("appId"),
|
||||||
|
fromId = obj.optString("fromId"),
|
||||||
|
toId = obj.optString("toId"),
|
||||||
|
chatType = obj.optString("chatType"),
|
||||||
|
msgType = obj.optString("msgType"),
|
||||||
|
content = obj.optString("content"),
|
||||||
|
status = obj.optString("status"),
|
||||||
|
mentionedUserIds = obj.optString("mentionedUserIds").takeIf { it.isNotBlank() },
|
||||||
|
createdAt = obj.optLong("createdAt"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeConversationList(conversations: List<ConversationData>): String {
|
||||||
|
val array = JSONArray()
|
||||||
|
conversations.forEach { conversation ->
|
||||||
|
array.put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("targetId", conversation.targetId)
|
||||||
|
put("chatType", conversation.chatType)
|
||||||
|
put("lastMsgContent", conversation.lastMsgContent ?: "")
|
||||||
|
put("lastMsgType", conversation.lastMsgType ?: "")
|
||||||
|
put("lastMsgTime", conversation.lastMsgTime)
|
||||||
|
put("unreadCount", conversation.unreadCount)
|
||||||
|
put("isMuted", conversation.isMuted)
|
||||||
|
put("isPinned", conversation.isPinned)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return array.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeMessageList(messages: List<ImMessage>): String {
|
||||||
|
val array = JSONArray()
|
||||||
|
messages.forEach { message ->
|
||||||
|
array.put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", message.id)
|
||||||
|
put("appId", message.appId)
|
||||||
|
put("fromId", message.fromId)
|
||||||
|
put("toId", message.toId)
|
||||||
|
put("chatType", message.chatType)
|
||||||
|
put("msgType", message.msgType)
|
||||||
|
put("content", message.content)
|
||||||
|
put("status", message.status)
|
||||||
|
put("mentionedUserIds", message.mentionedUserIds ?: "")
|
||||||
|
put("createdAt", message.createdAt)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return array.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ImMessage.matches(keyword: String): Boolean {
|
||||||
|
val plainText = when (msgType.uppercase()) {
|
||||||
|
"TEXT" -> runCatching { JSONObject(content).optString("text") }.getOrDefault(content)
|
||||||
|
"NOTIFY" -> runCatching { JSONObject(content).optString("content") }.getOrDefault(content)
|
||||||
|
else -> content
|
||||||
|
}
|
||||||
|
return plainText.contains(keyword, ignoreCase = true) ||
|
||||||
|
fromId.contains(keyword, ignoreCase = true) ||
|
||||||
|
toId.contains(keyword, ignoreCase = true) ||
|
||||||
|
msgType.contains(keyword, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId"
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFS_NAME = "xuqm_demo_im_cache"
|
||||||
|
const val KEY_CONVERSATIONS = "conversations"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,9 @@ package com.xuqm.sdk.sample.di
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||||
|
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
||||||
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
|
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
|
||||||
|
import com.xuqm.sdk.sample.data.local.LocalImCache
|
||||||
|
|
||||||
object AppDependencies {
|
object AppDependencies {
|
||||||
|
|
||||||
@ -10,10 +12,16 @@ object AppDependencies {
|
|||||||
private set
|
private set
|
||||||
lateinit var environmentRepository: EnvironmentRepository
|
lateinit var environmentRepository: EnvironmentRepository
|
||||||
private set
|
private set
|
||||||
|
lateinit var localImCache: LocalImCache
|
||||||
|
private set
|
||||||
|
lateinit var localContactCache: LocalContactCache
|
||||||
|
private set
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
environmentRepository = EnvironmentRepository(context)
|
environmentRepository = EnvironmentRepository(context)
|
||||||
environmentRepository.current()
|
environmentRepository.current()
|
||||||
|
localImCache = LocalImCache(context)
|
||||||
|
localContactCache = LocalContactCache(context)
|
||||||
authRepository = AuthRepository(context)
|
authRepository = AuthRepository(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package com.xuqm.sdk.sample.ui.chat
|
|||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -18,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@ -41,10 +41,11 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
|
||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.ui.InitialAvatar
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
||||||
|
import com.xuqm.sdk.ui.InitialAvatar
|
||||||
|
import com.xuqm.sdk.ui.SearchBarField
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -58,18 +59,24 @@ fun ChatScreen(
|
|||||||
viewModel: ChatViewModel = viewModel(),
|
viewModel: ChatViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||||
|
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
|
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||||
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
|
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
|
||||||
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
||||||
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
||||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
var input by remember { mutableStateOf("") }
|
var input by remember { mutableStateOf("") }
|
||||||
|
var showSearchBar by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
|
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
|
||||||
LaunchedEffect(scrollSignal) {
|
LaunchedEffect(scrollSignal) {
|
||||||
if (messages.isNotEmpty()) listState.animateScrollToItem(0)
|
if (messages.isNotEmpty() && searchQuery.isBlank()) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size) {
|
}
|
||||||
|
LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size, searchQuery) {
|
||||||
|
if (searchQuery.isNotBlank()) return@LaunchedEffect
|
||||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
|
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { lastVisibleIndex ->
|
.collect { lastVisibleIndex ->
|
||||||
@ -94,6 +101,16 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
showSearchBar = !showSearchBar
|
||||||
|
if (!showSearchBar) {
|
||||||
|
viewModel.clearSearch()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = "搜索")
|
||||||
|
}
|
||||||
if (chatType == "GROUP" && onGroupSettings != null) {
|
if (chatType == "GROUP" && onGroupSettings != null) {
|
||||||
IconButton(onClick = onGroupSettings) {
|
IconButton(onClick = onGroupSettings) {
|
||||||
Icon(Icons.Default.Settings, contentDescription = null)
|
Icon(Icons.Default.Settings, contentDescription = null)
|
||||||
@ -129,12 +146,52 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
if (showSearchBar || searchQuery.isNotBlank()) {
|
||||||
|
SearchBarField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = viewModel::searchCachedMessages,
|
||||||
|
placeholder = "搜索当前会话本地消息",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.isNotBlank()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
) {
|
||||||
|
items(searchResults, key = { it.id }) { msg ->
|
||||||
|
MessageBubble(
|
||||||
|
message = msg,
|
||||||
|
isOwn = msg.fromId == viewModel.currentUserId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (searchResults.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"没有找到匹配的本地消息",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
reverseLayout = true,
|
reverseLayout = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(padding),
|
.weight(1f),
|
||||||
) {
|
) {
|
||||||
items(messages, key = { it.id }) { msg ->
|
items(messages, key = { it.id }) { msg ->
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
@ -157,6 +214,8 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
||||||
|
|||||||
@ -5,12 +5,14 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.xuqm.sdk.im.ImSDK
|
import com.xuqm.sdk.im.ImSDK
|
||||||
import com.xuqm.sdk.im.listener.ImEventListener
|
import com.xuqm.sdk.im.listener.ImEventListener
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
|
import com.xuqm.sdk.sample.di.AppDependencies
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ChatViewModel : ViewModel() {
|
class ChatViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val cache = AppDependencies.localImCache
|
||||||
private val _messages = MutableStateFlow<List<ImMessage>>(emptyList())
|
private val _messages = MutableStateFlow<List<ImMessage>>(emptyList())
|
||||||
val messages: StateFlow<List<ImMessage>> = _messages
|
val messages: StateFlow<List<ImMessage>> = _messages
|
||||||
|
|
||||||
@ -23,6 +25,12 @@ class ChatViewModel : ViewModel() {
|
|||||||
private val _scrollToBottomSignal = MutableStateFlow(0L)
|
private val _scrollToBottomSignal = MutableStateFlow(0L)
|
||||||
val scrollToBottomSignal: StateFlow<Long> = _scrollToBottomSignal
|
val scrollToBottomSignal: StateFlow<Long> = _scrollToBottomSignal
|
||||||
|
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
val searchQuery: StateFlow<String> = _searchQuery
|
||||||
|
|
||||||
|
private val _searchResults = MutableStateFlow<List<ImMessage>>(emptyList())
|
||||||
|
val searchResults: StateFlow<List<ImMessage>> = _searchResults
|
||||||
|
|
||||||
val currentUserId: String get() = ImSDK.currentUserId
|
val currentUserId: String get() = ImSDK.currentUserId
|
||||||
|
|
||||||
private lateinit var targetId: String
|
private lateinit var targetId: String
|
||||||
@ -34,6 +42,10 @@ class ChatViewModel : ViewModel() {
|
|||||||
override fun onMessage(message: ImMessage) {
|
override fun onMessage(message: ImMessage) {
|
||||||
if (isRelevant(message)) {
|
if (isRelevant(message)) {
|
||||||
prependMessage(message)
|
prependMessage(message)
|
||||||
|
cache.mergeHistory(targetId, chatType, _messages.value)
|
||||||
|
if (_searchQuery.value.isNotBlank()) {
|
||||||
|
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||||
|
}
|
||||||
requestScrollToBottom()
|
requestScrollToBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -41,6 +53,10 @@ class ChatViewModel : ViewModel() {
|
|||||||
override fun onGroupMessage(message: ImMessage) {
|
override fun onGroupMessage(message: ImMessage) {
|
||||||
if (isRelevant(message)) {
|
if (isRelevant(message)) {
|
||||||
prependMessage(message)
|
prependMessage(message)
|
||||||
|
cache.mergeHistory(targetId, chatType, _messages.value)
|
||||||
|
if (_searchQuery.value.isNotBlank()) {
|
||||||
|
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||||
|
}
|
||||||
requestScrollToBottom()
|
requestScrollToBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,9 +68,11 @@ class ChatViewModel : ViewModel() {
|
|||||||
this.chatType = chatType
|
this.chatType = chatType
|
||||||
nextHistoryPage = 0
|
nextHistoryPage = 0
|
||||||
initialized = true
|
initialized = true
|
||||||
_messages.value = emptyList()
|
_messages.value = cache.getHistoryPage(targetId, chatType, 0, HISTORY_PAGE_SIZE)
|
||||||
_hasMoreHistory.value = true
|
_hasMoreHistory.value = true
|
||||||
_isLoadingMore.value = false
|
_isLoadingMore.value = false
|
||||||
|
_searchQuery.value = ""
|
||||||
|
_searchResults.value = emptyList()
|
||||||
ImSDK.addListener(listener)
|
ImSDK.addListener(listener)
|
||||||
loadInitialHistory()
|
loadInitialHistory()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -86,18 +104,31 @@ class ChatViewModel : ViewModel() {
|
|||||||
nextHistoryPage += 1
|
nextHistoryPage += 1
|
||||||
}
|
}
|
||||||
_hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE
|
_hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE
|
||||||
|
if (history.isNotEmpty()) {
|
||||||
|
cache.saveHistory(targetId, chatType, mergeHistory(_messages.value))
|
||||||
|
}
|
||||||
_isLoadingMore.value = false
|
_isLoadingMore.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
||||||
return if (chatType == "GROUP") {
|
val remote = if (chatType == "GROUP") {
|
||||||
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||||
.getOrDefault(emptyList())
|
.getOrNull()
|
||||||
} else {
|
} else {
|
||||||
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||||
.getOrDefault(emptyList())
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
if (remote != null) {
|
||||||
|
val normalized = remote.sortedByDescending { it.createdAt }
|
||||||
|
if (page == 0) {
|
||||||
|
cache.saveHistory(targetId, chatType, normalized)
|
||||||
|
} else {
|
||||||
|
cache.mergeHistory(targetId, chatType, normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return cache.getHistoryPage(targetId, chatType, page, HISTORY_PAGE_SIZE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendText(content: String) {
|
fun sendText(content: String) {
|
||||||
@ -105,11 +136,28 @@ class ChatViewModel : ViewModel() {
|
|||||||
ImSDK.sendMessage(targetId, chatType, "TEXT", content)
|
ImSDK.sendMessage(targetId, chatType, "TEXT", content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun searchCachedMessages(query: String) {
|
||||||
|
_searchQuery.value = query
|
||||||
|
_searchResults.value = if (query.isBlank()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
cache.searchHistory(targetId, chatType, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSearch() {
|
||||||
|
_searchQuery.value = ""
|
||||||
|
_searchResults.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
private fun prependMessage(message: ImMessage) {
|
private fun prependMessage(message: ImMessage) {
|
||||||
if (_messages.value.any { it.id == message.id }) return
|
if (_messages.value.any { it.id == message.id }) return
|
||||||
_messages.value = listOf(message) + _messages.value
|
_messages.value = listOf(message) + _messages.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> =
|
||||||
|
messages.distinctBy { it.id }.sortedByDescending { it.createdAt }
|
||||||
|
|
||||||
private fun requestScrollToBottom() {
|
private fun requestScrollToBottom() {
|
||||||
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
|
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.xuqm.sdk.im.model.ConversationData
|
import com.xuqm.sdk.im.model.ConversationData
|
||||||
import com.xuqm.sdk.ui.InitialAvatar
|
import com.xuqm.sdk.ui.InitialAvatar
|
||||||
|
import com.xuqm.sdk.ui.SearchBarField
|
||||||
import com.xuqm.sdk.utils.TimeFormatters
|
import com.xuqm.sdk.utils.TimeFormatters
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@ -45,9 +46,39 @@ fun ConversationScreen(
|
|||||||
) {
|
) {
|
||||||
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
|
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val filtered = remember(conversations, query) {
|
||||||
|
conversations.filter { conversation ->
|
||||||
|
if (query.isBlank()) return@filter true
|
||||||
|
val keyword = query.trim()
|
||||||
|
conversation.targetId.contains(keyword, ignoreCase = true) ||
|
||||||
|
(conversation.lastMsgContent ?: "").contains(keyword, ignoreCase = true) ||
|
||||||
|
conversation.chatType.contains(keyword, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
items(conversations, key = { "${it.chatType}_${it.targetId}" }) { conv ->
|
SearchBarField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = "搜索会话 / 最近消息",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filtered.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"没有匹配的会话",
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
) {
|
||||||
|
items(filtered, key = { "${it.chatType}_${it.targetId}" }) { conv ->
|
||||||
ConversationItem(
|
ConversationItem(
|
||||||
conversation = conv,
|
conversation = conv,
|
||||||
onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) },
|
onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) },
|
||||||
@ -62,6 +93,8 @@ fun ConversationScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import com.xuqm.sdk.im.ImSDK
|
|||||||
import com.xuqm.sdk.im.listener.ImEventListener
|
import com.xuqm.sdk.im.listener.ImEventListener
|
||||||
import com.xuqm.sdk.im.model.ConversationData
|
import com.xuqm.sdk.im.model.ConversationData
|
||||||
import com.xuqm.sdk.im.model.ImMessage
|
import com.xuqm.sdk.im.model.ImMessage
|
||||||
|
import com.xuqm.sdk.sample.di.AppDependencies
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ConversationViewModel : ViewModel() {
|
class ConversationViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val cache = AppDependencies.localImCache
|
||||||
private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList())
|
private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList())
|
||||||
val conversations: StateFlow<List<ConversationData>> = _conversations
|
val conversations: StateFlow<List<ConversationData>> = _conversations
|
||||||
|
|
||||||
@ -22,6 +24,7 @@ class ConversationViewModel : ViewModel() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
ImSDK.addListener(listener)
|
ImSDK.addListener(listener)
|
||||||
|
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,7 +32,13 @@ class ConversationViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ImSDK.listConversations() }
|
runCatching { ImSDK.listConversations() }
|
||||||
.onSuccess { list ->
|
.onSuccess { list ->
|
||||||
_conversations.value = list.sortedByDescending { it.lastMsgTime }
|
val sorted = list.sortedByDescending { it.lastMsgTime }
|
||||||
|
cache.saveConversations(sorted)
|
||||||
|
_conversations.value = sorted
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||||
|
if (cached.isNotEmpty()) _conversations.value = cached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户