feat(chat): 添加聊天界面和会话管理功能

- 实现了本地IM缓存功能,支持会话、消息历史和草稿的存储
- 开发了聊天界面UI组件,包含消息列表、输入框和搜索功能
- 创建了聊天相关的ViewModel,处理消息收发和状态管理
- 构建了会话列表界面,支持置顶、免打扰和删除操作
- 集成了群组功能,实现群聊管理和群设置界面
- 添加了实时消息推送和会话状态同步机制
这个提交包含在:
XuqmGroup 2026-04-27 23:41:58 +08:00
父节点 36f044f7b7
当前提交 59611de3c1
共有 9 个文件被更改,包括 207 次插入13 次删除

查看文件

@ -139,6 +139,12 @@ val service = RetrofitFactory.create(MyApiService::class.java)
- 联系人列表先读本地缓存,再刷新网络 - 联系人列表先读本地缓存,再刷新网络
- 聊天历史分页加载 - 聊天历史分页加载
- 当前会话本地搜索 - 当前会话本地搜索
- 输入草稿自动保存
- 群设置支持编辑群名和群公告
- 会话支持本地删除
- 显示总未读数
- 消息状态直接展示
- 会话置顶/免打扰/已读/草稿/删除同步服务端
- IM 连接状态提示 - IM 连接状态提示
- SDK 登录态恢复后自动重连 - SDK 登录态恢复后自动重连

查看文件

@ -13,10 +13,12 @@ class LocalImCache(context: Context) {
fun loadConversations(): List<ConversationData> = readConversationList(KEY_CONVERSATIONS) fun loadConversations(): List<ConversationData> = readConversationList(KEY_CONVERSATIONS)
fun saveConversations(conversations: List<ConversationData>) { fun saveConversations(conversations: List<ConversationData>) {
prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(conversations)).apply() val visible = conversations.filterNot { isHiddenConversation(it.targetId, it.chatType) }
prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(visible)).apply()
} }
fun upsertConversation(conversation: ConversationData) { fun upsertConversation(conversation: ConversationData) {
clearHiddenConversation(conversation.targetId, conversation.chatType)
val existing = loadConversations().firstOrNull { val existing = loadConversations().firstOrNull {
it.targetId == conversation.targetId && it.chatType == conversation.chatType it.targetId == conversation.targetId && it.chatType == conversation.chatType
} }
@ -64,6 +66,29 @@ class LocalImCache(context: Context) {
saveHistory(targetId, chatType, merged) saveHistory(targetId, chatType, merged)
} }
fun saveDraft(targetId: String, chatType: String, draft: String) {
prefs.edit().putString(draftKey(targetId, chatType), draft).apply()
}
fun loadDraft(targetId: String, chatType: String): String =
prefs.getString(draftKey(targetId, chatType), "") ?: ""
fun clearDraft(targetId: String, chatType: String) {
prefs.edit().remove(draftKey(targetId, chatType)).apply()
}
fun deleteConversation(targetId: String, chatType: String) {
val remaining = loadConversations().filterNot {
it.targetId == targetId && it.chatType == chatType
}
prefs.edit()
.putString(KEY_CONVERSATIONS, serializeConversationList(remaining))
.remove(historyKey(targetId, chatType))
.remove(draftKey(targetId, chatType))
.putStringSet(KEY_HIDDEN_CONVERSATIONS, loadHiddenConversations() + conversationKey(targetId, chatType))
.apply()
}
private fun readConversationList(key: String): List<ConversationData> { private fun readConversationList(key: String): List<ConversationData> {
val raw = prefs.getString(key, null) ?: return emptyList() val raw = prefs.getString(key, null) ?: return emptyList()
return runCatching { return runCatching {
@ -167,9 +192,25 @@ class LocalImCache(context: Context) {
} }
private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId" private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId"
private fun draftKey(targetId: String, chatType: String) = "draft_${chatType}_$targetId"
private fun conversationKey(targetId: String, chatType: String) = "${chatType}_$targetId"
private fun loadHiddenConversations(): MutableSet<String> =
prefs.getStringSet(KEY_HIDDEN_CONVERSATIONS, emptySet())?.toMutableSet() ?: mutableSetOf()
private fun isHiddenConversation(targetId: String, chatType: String): Boolean =
loadHiddenConversations().contains(conversationKey(targetId, chatType))
private fun clearHiddenConversation(targetId: String, chatType: String) {
val hidden = loadHiddenConversations()
if (hidden.remove(conversationKey(targetId, chatType))) {
prefs.edit().putStringSet(KEY_HIDDEN_CONVERSATIONS, hidden).apply()
}
}
private companion object { private companion object {
const val PREFS_NAME = "xuqm_demo_im_cache" const val PREFS_NAME = "xuqm_demo_im_cache"
const val KEY_CONVERSATIONS = "conversations" const val KEY_CONVERSATIONS = "conversations"
const val KEY_HIDDEN_CONVERSATIONS = "hidden_conversations"
} }
} }

查看文件

@ -61,12 +61,12 @@ fun ChatScreen(
val messages by viewModel.messages.collectAsStateWithLifecycle() val messages by viewModel.messages.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val draftText by viewModel.draftText.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 showSearchBar by remember { mutableStateOf(false) } var showSearchBar by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
@ -130,16 +130,16 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedTextField( OutlinedTextField(
value = input, value = draftText,
onValueChange = { input = it }, onValueChange = viewModel::updateDraft,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { Text("输入消息…") }, placeholder = { Text("输入消息…") },
maxLines = 4, maxLines = 4,
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
) )
IconButton( IconButton(
onClick = { viewModel.sendText(input); input = "" }, onClick = { viewModel.sendText(draftText) },
enabled = input.isNotBlank(), enabled = draftText.isNotBlank(),
) { ) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
} }
@ -246,11 +246,19 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
.widthIn(max = 280.dp) .widthIn(max = 280.dp)
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
) { ) {
Text( Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
text = parseContent(message), Text(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), text = parseContent(message),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
if (isOwn) {
Text(
text = statusLabel(message.status),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
} }
if (isOwn) { if (isOwn) {
@ -259,6 +267,16 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
} }
} }
private fun statusLabel(status: String): String = when (status.uppercase()) {
"SENDING" -> "发送中"
"SENT" -> "已发送"
"DELIVERED" -> "已送达"
"READ" -> "已读"
"FAILED" -> "发送失败"
"REVOKED" -> "已撤回"
else -> status
}
@Composable @Composable
private fun AvatarPlaceholder(userId: String) { private fun AvatarPlaceholder(userId: String) {
InitialAvatar(text = userId, modifier = Modifier.size(32.dp)) InitialAvatar(text = userId, modifier = Modifier.size(32.dp))

查看文件

@ -32,6 +32,9 @@ class ChatViewModel : ViewModel() {
private val _searchResults = MutableStateFlow<List<ImMessage>>(emptyList()) private val _searchResults = MutableStateFlow<List<ImMessage>>(emptyList())
val searchResults: StateFlow<List<ImMessage>> = _searchResults val searchResults: StateFlow<List<ImMessage>> = _searchResults
private val _draftText = MutableStateFlow("")
val draftText: StateFlow<String> = _draftText
val currentUserId: String get() = ImSDK.currentUserId val currentUserId: String get() = ImSDK.currentUserId
private lateinit var targetId: String private lateinit var targetId: String
@ -98,6 +101,7 @@ class ChatViewModel : ViewModel() {
_isLoadingMore.value = false _isLoadingMore.value = false
_searchQuery.value = "" _searchQuery.value = ""
_searchResults.value = emptyList() _searchResults.value = emptyList()
_draftText.value = cache.loadDraft(targetId, chatType)
ImSDK.addListener(listener) ImSDK.addListener(listener)
loadInitialHistory() loadInitialHistory()
viewModelScope.launch { viewModelScope.launch {
@ -173,6 +177,8 @@ class ChatViewModel : ViewModel() {
fun sendText(content: String) { fun sendText(content: String) {
if (content.isBlank()) return if (content.isBlank()) return
ImSDK.sendMessage(targetId, chatType, "TEXT", content) ImSDK.sendMessage(targetId, chatType, "TEXT", content)
_draftText.value = ""
cache.clearDraft(targetId, chatType)
cache.upsertConversation( cache.upsertConversation(
ConversationData( ConversationData(
targetId = targetId, targetId = targetId,
@ -196,6 +202,16 @@ class ChatViewModel : ViewModel() {
} }
} }
fun updateDraft(text: String) {
_draftText.value = text
if (initialized) {
cache.saveDraft(targetId, chatType, text)
viewModelScope.launch {
runCatching { ImSDK.setDraft(targetId, chatType, text) }
}
}
}
fun clearSearch() { fun clearSearch() {
_searchQuery.value = "" _searchQuery.value = ""
_searchResults.value = emptyList() _searchResults.value = emptyList()

查看文件

@ -45,6 +45,7 @@ fun ConversationScreen(
viewModel: ConversationViewModel = viewModel(), viewModel: ConversationViewModel = viewModel(),
) { ) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle() val conversations by viewModel.conversations.collectAsStateWithLifecycle()
val totalUnreadCount by viewModel.totalUnreadCount.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val filtered = remember(conversations, query) { val filtered = remember(conversations, query) {
@ -58,6 +59,12 @@ fun ConversationScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Text(
"总未读 $totalUnreadCount",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline,
)
SearchBarField( SearchBarField(
value = query, value = query,
onValueChange = { query = it }, onValueChange = { query = it },
@ -88,6 +95,9 @@ fun ConversationScreen(
onMuteToggle = { onMuteToggle = {
scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) } scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) }
}, },
onDelete = {
viewModel.deleteConversation(conv.targetId, conv.chatType)
},
) )
HorizontalDivider(modifier = Modifier.padding(start = 72.dp)) HorizontalDivider(modifier = Modifier.padding(start = 72.dp))
} }
@ -103,6 +113,7 @@ private fun ConversationItem(
onClick: () -> Unit, onClick: () -> Unit,
onPinToggle: () -> Unit, onPinToggle: () -> Unit,
onMuteToggle: () -> Unit, onMuteToggle: () -> Unit,
onDelete: () -> Unit,
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@ -160,6 +171,10 @@ private fun ConversationItem(
text = { Text(if (conversation.isMuted) "取消免打扰" else "免打扰") }, text = { Text(if (conversation.isMuted) "取消免打扰" else "免打扰") },
onClick = { showMenu = false; onMuteToggle() }, onClick = { showMenu = false; onMuteToggle() },
) )
DropdownMenuItem(
text = { Text("删除会话") },
onClick = { showMenu = false; onDelete() },
)
} }
} }
} }

查看文件

@ -18,6 +18,9 @@ class ConversationViewModel : ViewModel() {
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
private val _totalUnreadCount = MutableStateFlow(0)
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount
private val listener = object : ImEventListener { private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) { refresh() } override fun onMessage(message: ImMessage) { refresh() }
override fun onGroupMessage(message: ImMessage) { refresh() } override fun onGroupMessage(message: ImMessage) { refresh() }
@ -26,6 +29,7 @@ class ConversationViewModel : ViewModel() {
init { init {
ImSDK.addListener(listener) ImSDK.addListener(listener)
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
refresh() refresh()
viewModelScope.launch { viewModelScope.launch {
ImSDK.connectionState.collect { state -> ImSDK.connectionState.collect { state ->
@ -43,14 +47,27 @@ class ConversationViewModel : ViewModel() {
val sorted = list.sortedByDescending { it.lastMsgTime } val sorted = list.sortedByDescending { it.lastMsgTime }
cache.saveConversations(sorted) cache.saveConversations(sorted)
_conversations.value = sorted _conversations.value = sorted
_totalUnreadCount.value = sorted.sumOf { it.unreadCount }
} }
.onFailure { .onFailure {
val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime } val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime }
if (cached.isNotEmpty()) _conversations.value = cached if (cached.isNotEmpty()) {
_conversations.value = cached
_totalUnreadCount.value = cached.sumOf { it.unreadCount }
}
} }
} }
} }
fun deleteConversation(targetId: String, chatType: String) {
viewModelScope.launch {
runCatching { ImSDK.deleteConversation(targetId, chatType) }
cache.deleteConversation(targetId, chatType)
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
}
}
suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) { suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) {
runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) } runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) }
refresh() refresh()

查看文件

@ -155,8 +155,15 @@ fun GroupSettingsScreen(
) { ) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle() val group by viewModel.currentGroup.collectAsStateWithLifecycle()
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
var showEditDialog by remember { mutableStateOf(false) }
var editName by remember { mutableStateOf("") }
var editAnnouncement by remember { mutableStateOf("") }
LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) } LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) }
LaunchedEffect(group) {
editName = group?.name.orEmpty()
editAnnouncement = group?.announcement.orEmpty()
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -177,13 +184,20 @@ fun GroupSettingsScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(16.dp), .padding(16.dp),
) { ) {
group?.let { g -> group?.let { g ->
val isOwner = g.creatorId == ImSDK.currentUserId
Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall, Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline) color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium) Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium)
if (isOwner) {
Spacer(Modifier.height(12.dp))
TextButton(onClick = { showEditDialog = true }) {
Text("编辑群资料")
}
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("成员", style = MaterialTheme.typography.titleSmall) Text("成员", style = MaterialTheme.typography.titleSmall)
val memberIds = g.memberIds.split(",").filter { it.isNotBlank() } val memberIds = g.memberIds.split(",").filter { it.isNotBlank() }
@ -209,4 +223,45 @@ fun GroupSettingsScreen(
} }
} }
} }
if (showEditDialog && group != null) {
AlertDialog(
onDismissRequest = { showEditDialog = false },
title = { Text("编辑群资料") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = editName,
onValueChange = { editName = it },
label = { Text("群名称") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = editAnnouncement,
onValueChange = { editAnnouncement = it },
label = { Text("群公告") },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
showEditDialog = false
viewModel.updateGroup(
groupId = groupId,
name = editName.trim().ifBlank { null },
announcement = editAnnouncement.trim().ifBlank { null },
)
},
enabled = editName.isNotBlank(),
) {
Text("保存")
}
},
dismissButton = {
TextButton(onClick = { showEditDialog = false }) { Text("取消") }
},
)
}
} }

查看文件

@ -116,6 +116,11 @@ object ImSDK {
suspend fun listConversations(): List<ConversationData> = suspend fun listConversations(): List<ConversationData> =
withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() } withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() }
suspend fun getTotalUnreadCount(): Int =
withContext(Dispatchers.IO) {
runCatching { listConversations().sumOf { it.unreadCount } }.getOrDefault(0)
}
suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) = suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
api.setConversationPinned(targetId, chatType, SetPinnedRequest(pinned)) api.setConversationPinned(targetId, chatType, SetPinnedRequest(pinned))
@ -129,6 +134,12 @@ object ImSDK {
suspend fun markRead(targetId: String, chatType: String = "SINGLE") = suspend fun markRead(targetId: String, chatType: String = "SINGLE") =
withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, chatType) } withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, chatType) }
suspend fun setDraft(targetId: String, chatType: String, draft: String) =
withContext(Dispatchers.IO) { api.setDraft(targetId, XuqmSDK.appId, chatType, draft) }
suspend fun deleteConversation(targetId: String, chatType: String) =
withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appId, chatType) }
fun addListener(listener: ImEventListener) = client?.addListener(listener) fun addListener(listener: ImEventListener) = client?.addListener(listener)
fun removeListener(listener: ImEventListener) = client?.removeListener(listener) fun removeListener(listener: ImEventListener) = client?.removeListener(listener)

查看文件

@ -129,4 +129,19 @@ interface ImApi {
@Query("appId") appId: String, @Query("appId") appId: String,
@Query("chatType") chatType: String, @Query("chatType") chatType: String,
): ApiResponse<Unit> ): ApiResponse<Unit>
@PUT("api/im/conversations/{targetId}/draft")
suspend fun setDraft(
@Path("targetId") targetId: String,
@Query("appId") appId: String,
@Query("chatType") chatType: String,
@Query("draft") draft: String,
): ApiResponse<Unit>
@DELETE("api/im/conversations/{targetId}")
suspend fun deleteConversation(
@Path("targetId") targetId: String,
@Query("appId") appId: String,
@Query("chatType") chatType: String,
): ApiResponse<Unit>
} }