feat(chat): 添加聊天界面和会话管理功能
- 实现了本地IM缓存功能,支持会话、消息历史和草稿的存储 - 开发了聊天界面UI组件,包含消息列表、输入框和搜索功能 - 创建了聊天相关的ViewModel,处理消息收发和状态管理 - 构建了会话列表界面,支持置顶、免打扰和删除操作 - 集成了群组功能,实现群聊管理和群设置界面 - 添加了实时消息推送和会话状态同步机制
这个提交包含在:
父节点
36f044f7b7
当前提交
59611de3c1
@ -139,6 +139,12 @@ val service = RetrofitFactory.create(MyApiService::class.java)
|
||||
- 联系人列表先读本地缓存,再刷新网络
|
||||
- 聊天历史分页加载
|
||||
- 当前会话本地搜索
|
||||
- 输入草稿自动保存
|
||||
- 群设置支持编辑群名和群公告
|
||||
- 会话支持本地删除
|
||||
- 显示总未读数
|
||||
- 消息状态直接展示
|
||||
- 会话置顶/免打扰/已读/草稿/删除同步服务端
|
||||
- IM 连接状态提示
|
||||
- SDK 登录态恢复后自动重连
|
||||
|
||||
|
||||
@ -13,10 +13,12 @@ class LocalImCache(context: Context) {
|
||||
fun loadConversations(): List<ConversationData> = readConversationList(KEY_CONVERSATIONS)
|
||||
|
||||
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) {
|
||||
clearHiddenConversation(conversation.targetId, conversation.chatType)
|
||||
val existing = loadConversations().firstOrNull {
|
||||
it.targetId == conversation.targetId && it.chatType == conversation.chatType
|
||||
}
|
||||
@ -64,6 +66,29 @@ class LocalImCache(context: Context) {
|
||||
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> {
|
||||
val raw = prefs.getString(key, null) ?: return emptyList()
|
||||
return runCatching {
|
||||
@ -167,9 +192,25 @@ class LocalImCache(context: Context) {
|
||||
}
|
||||
|
||||
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 {
|
||||
const val PREFS_NAME = "xuqm_demo_im_cache"
|
||||
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 searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
|
||||
val draftText by viewModel.draftText.collectAsStateWithLifecycle()
|
||||
val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
|
||||
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
|
||||
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
|
||||
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
var input by remember { mutableStateOf("") }
|
||||
var showSearchBar by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
|
||||
@ -130,16 +130,16 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
value = draftText,
|
||||
onValueChange = viewModel::updateDraft,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("输入消息…") },
|
||||
maxLines = 4,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.sendText(input); input = "" },
|
||||
enabled = input.isNotBlank(),
|
||||
onClick = { viewModel.sendText(draftText) },
|
||||
enabled = draftText.isNotBlank(),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
|
||||
}
|
||||
@ -246,11 +246,19 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) {
|
||||
.widthIn(max = 280.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = parseContent(message),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = parseContent(message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (isOwn) {
|
||||
Text(
|
||||
text = statusLabel(message.status),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private fun AvatarPlaceholder(userId: String) {
|
||||
InitialAvatar(text = userId, modifier = Modifier.size(32.dp))
|
||||
|
||||
@ -32,6 +32,9 @@ class ChatViewModel : ViewModel() {
|
||||
private val _searchResults = MutableStateFlow<List<ImMessage>>(emptyList())
|
||||
val searchResults: StateFlow<List<ImMessage>> = _searchResults
|
||||
|
||||
private val _draftText = MutableStateFlow("")
|
||||
val draftText: StateFlow<String> = _draftText
|
||||
|
||||
val currentUserId: String get() = ImSDK.currentUserId
|
||||
|
||||
private lateinit var targetId: String
|
||||
@ -98,6 +101,7 @@ class ChatViewModel : ViewModel() {
|
||||
_isLoadingMore.value = false
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
_draftText.value = cache.loadDraft(targetId, chatType)
|
||||
ImSDK.addListener(listener)
|
||||
loadInitialHistory()
|
||||
viewModelScope.launch {
|
||||
@ -173,6 +177,8 @@ class ChatViewModel : ViewModel() {
|
||||
fun sendText(content: String) {
|
||||
if (content.isBlank()) return
|
||||
ImSDK.sendMessage(targetId, chatType, "TEXT", content)
|
||||
_draftText.value = ""
|
||||
cache.clearDraft(targetId, chatType)
|
||||
cache.upsertConversation(
|
||||
ConversationData(
|
||||
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() {
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
|
||||
@ -45,6 +45,7 @@ fun ConversationScreen(
|
||||
viewModel: ConversationViewModel = viewModel(),
|
||||
) {
|
||||
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
|
||||
val totalUnreadCount by viewModel.totalUnreadCount.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered = remember(conversations, query) {
|
||||
@ -58,6 +59,12 @@ fun ConversationScreen(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"总未读 $totalUnreadCount",
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
SearchBarField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
@ -88,6 +95,9 @@ fun ConversationScreen(
|
||||
onMuteToggle = {
|
||||
scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) }
|
||||
},
|
||||
onDelete = {
|
||||
viewModel.deleteConversation(conv.targetId, conv.chatType)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 72.dp))
|
||||
}
|
||||
@ -103,6 +113,7 @@ private fun ConversationItem(
|
||||
onClick: () -> Unit,
|
||||
onPinToggle: () -> Unit,
|
||||
onMuteToggle: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
@ -160,6 +171,10 @@ private fun ConversationItem(
|
||||
text = { Text(if (conversation.isMuted) "取消免打扰" else "免打扰") },
|
||||
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())
|
||||
val conversations: StateFlow<List<ConversationData>> = _conversations
|
||||
|
||||
private val _totalUnreadCount = MutableStateFlow(0)
|
||||
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount
|
||||
|
||||
private val listener = object : ImEventListener {
|
||||
override fun onMessage(message: ImMessage) { refresh() }
|
||||
override fun onGroupMessage(message: ImMessage) { refresh() }
|
||||
@ -26,6 +29,7 @@ class ConversationViewModel : ViewModel() {
|
||||
init {
|
||||
ImSDK.addListener(listener)
|
||||
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||
_totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount }
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
ImSDK.connectionState.collect { state ->
|
||||
@ -43,14 +47,27 @@ class ConversationViewModel : ViewModel() {
|
||||
val sorted = list.sortedByDescending { it.lastMsgTime }
|
||||
cache.saveConversations(sorted)
|
||||
_conversations.value = sorted
|
||||
_totalUnreadCount.value = sorted.sumOf { it.unreadCount }
|
||||
}
|
||||
.onFailure {
|
||||
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) {
|
||||
runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) }
|
||||
refresh()
|
||||
|
||||
@ -155,8 +155,15 @@ fun GroupSettingsScreen(
|
||||
) {
|
||||
val group by viewModel.currentGroup.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(group) {
|
||||
editName = group?.name.orEmpty()
|
||||
editAnnouncement = group?.announcement.orEmpty()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@ -177,13 +184,20 @@ fun GroupSettingsScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
.padding(16.dp),
|
||||
) {
|
||||
group?.let { g ->
|
||||
val isOwner = g.creatorId == ImSDK.currentUserId
|
||||
Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium)
|
||||
if (isOwner) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton(onClick = { showEditDialog = true }) {
|
||||
Text("编辑群资料")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("成员", style = MaterialTheme.typography.titleSmall)
|
||||
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> =
|
||||
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) =
|
||||
withContext(Dispatchers.IO) {
|
||||
api.setConversationPinned(targetId, chatType, SetPinnedRequest(pinned))
|
||||
@ -129,6 +134,12 @@ object ImSDK {
|
||||
suspend fun markRead(targetId: String, chatType: String = "SINGLE") =
|
||||
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 removeListener(listener: ImEventListener) = client?.removeListener(listener)
|
||||
|
||||
|
||||
@ -129,4 +129,19 @@ interface ImApi {
|
||||
@Query("appId") appId: String,
|
||||
@Query("chatType") chatType: String,
|
||||
): 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>
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户