feat(chat): 添加聊天功能和用户认证仓库

- 新增AuthRepository用于用户认证和会话管理
- 实现聊天界面ChatScreen支持单聊和群聊
- 添加聊天视图模型ChatViewModel处理消息逻辑
- 创建群组管理界面GroupScreen支持群组操作
- 实现主界面MainScreen整合各功能模块
- 添加应用启动类XuqmSampleApp初始化配置
- 在ImSDK中增加连接状态管理功能
这个提交包含在:
XuqmGroup 2026-04-27 19:41:26 +08:00
父节点 c318a318a3
当前提交 64fefd1bbb
共有 9 个文件被更改,包括 257 次插入40 次删除

查看文件

@ -4,9 +4,15 @@ import android.app.Application
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.sample.di.AppDependencies
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class XuqmSampleApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
AppDependencies.init(this)
@ -15,5 +21,8 @@ class XuqmSampleApp : Application() {
appId = "ak_demo_chat",
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN,
)
appScope.launch {
AppDependencies.authRepository.restoreSdkSession()
}
}
}

查看文件

@ -38,6 +38,7 @@ class AuthRepository(context: Context) {
fun getCurrentUserId(): String? = prefs.getString("user_id", null)
fun getCurrentNickname(): String? = prefs.getString("nickname", null)
fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
fun isLoggedIn(): Boolean = getDemoToken() != null
private fun saveSession(result: AuthResult) {
@ -113,6 +114,21 @@ class AuthRepository(context: Context) {
runCatching { api.searchUsers(keyword = keyword).data ?: emptyList() }
}
suspend fun restoreSdkSession(): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
val userId = getCurrentUserId()
val userSig = getCurrentUserSig()
if (userId.isNullOrBlank() || userSig.isNullOrBlank()) return@runCatching
XuqmSDK.login(
userId = userId,
userSig = userSig,
nickname = getCurrentNickname(),
avatar = getCurrentAvatar(),
)
}
}
fun logout() {
XuqmSDK.logout()
prefs.edit().clear().apply()

查看文件

@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -41,7 +42,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.ui.InitialAvatar
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -54,31 +58,51 @@ fun ChatScreen(
viewModel: ChatViewModel = viewModel(),
) {
val messages by viewModel.messages.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("") }
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
LaunchedEffect(messages.size) {
LaunchedEffect(scrollSignal) {
if (messages.isNotEmpty()) listState.animateScrollToItem(0)
}
LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
.distinctUntilChanged()
.collect { lastVisibleIndex ->
val shouldLoadMore = hasMoreHistory &&
!isLoadingMore &&
messages.isNotEmpty() &&
lastVisibleIndex >= messages.lastIndex - 2
if (shouldLoadMore) {
viewModel.loadMoreHistory()
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(targetName) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (chatType == "GROUP" && onGroupSettings != null) {
IconButton(onClick = onGroupSettings) {
Icon(Icons.Default.Settings, contentDescription = null)
Column {
TopAppBar(
title = { Text(targetName) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
},
)
},
actions = {
if (chatType == "GROUP" && onGroupSettings != null) {
IconButton(onClick = onGroupSettings) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
},
)
ConnectionStatusBanner(connectionState)
}
},
bottomBar = {
Row(
@ -118,6 +142,18 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId,
)
}
if (isLoadingMore) {
item(key = "loading-more") {
Text(
"加载历史中...",
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
}

查看文件

@ -14,42 +14,89 @@ class ChatViewModel : ViewModel() {
private val _messages = MutableStateFlow<List<ImMessage>>(emptyList())
val messages: StateFlow<List<ImMessage>> = _messages
private val _isLoadingMore = MutableStateFlow(false)
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore
private val _hasMoreHistory = MutableStateFlow(true)
val hasMoreHistory: StateFlow<Boolean> = _hasMoreHistory
private val _scrollToBottomSignal = MutableStateFlow(0L)
val scrollToBottomSignal: StateFlow<Long> = _scrollToBottomSignal
val currentUserId: String get() = ImSDK.currentUserId
private lateinit var targetId: String
private lateinit var chatType: String
private var nextHistoryPage = 0
private var initialized = false
private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) {
if (message.fromId == targetId || message.toId == targetId) {
_messages.value = listOf(message) + _messages.value
if (isRelevant(message)) {
prependMessage(message)
requestScrollToBottom()
}
}
override fun onGroupMessage(message: ImMessage) {
if (message.toId == targetId) {
_messages.value = listOf(message) + _messages.value
if (isRelevant(message)) {
prependMessage(message)
requestScrollToBottom()
}
}
}
fun init(targetId: String, chatType: String) {
if (initialized && this.targetId == targetId && this.chatType == chatType) return
this.targetId = targetId
this.chatType = chatType
nextHistoryPage = 0
initialized = true
_messages.value = emptyList()
_hasMoreHistory.value = true
_isLoadingMore.value = false
ImSDK.addListener(listener)
loadHistory()
loadInitialHistory()
viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) }
}
}
fun loadHistory() {
fun loadInitialHistory() {
if (!initialized) return
loadPage(replace = true)
}
fun loadMoreHistory() {
if (!initialized || _isLoadingMore.value || !_hasMoreHistory.value) return
loadPage(replace = false)
}
private fun loadPage(replace: Boolean) {
viewModelScope.launch {
val history = if (chatType == "GROUP") {
runCatching { ImSDK.fetchGroupHistory(targetId) }.getOrDefault(emptyList())
} else {
runCatching { ImSDK.fetchHistory(targetId) }.getOrDefault(emptyList())
_isLoadingMore.value = true
val page = if (replace) 0 else nextHistoryPage
val history = fetchHistory(page)
if (replace) {
_messages.value = history
nextHistoryPage = 1
requestScrollToBottom()
} else if (history.isNotEmpty()) {
_messages.value = (_messages.value + history).distinctBy { it.id }
nextHistoryPage += 1
}
_messages.value = history
_hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE
_isLoadingMore.value = false
}
}
private suspend fun fetchHistory(page: Int): List<ImMessage> {
return if (chatType == "GROUP") {
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
.getOrDefault(emptyList())
} else {
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
.getOrDefault(emptyList())
}
}
@ -58,7 +105,29 @@ class ChatViewModel : ViewModel() {
ImSDK.sendMessage(targetId, chatType, "TEXT", content)
}
private fun prependMessage(message: ImMessage) {
if (_messages.value.any { it.id == message.id }) return
_messages.value = listOf(message) + _messages.value
}
private fun requestScrollToBottom() {
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
}
private fun isRelevant(message: ImMessage): Boolean {
return if (chatType == "GROUP") {
message.chatType == "GROUP" && message.toId == targetId
} else {
message.chatType != "GROUP" && (message.fromId == targetId || message.toId == targetId)
}
}
override fun onCleared() {
ImSDK.removeListener(listener)
initialized = false
}
private companion object {
const val HISTORY_PAGE_SIZE = 20
}
}

查看文件

@ -0,0 +1,47 @@
package com.xuqm.sdk.sample.ui.common
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.xuqm.sdk.im.model.ImConnectionState
@Composable
fun ConnectionStatusBanner(state: ImConnectionState) {
when (state) {
ImConnectionState.Connected -> Unit
ImConnectionState.Connecting -> StatusBanner(
text = "IM 连接中...",
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
is ImConnectionState.Disconnected -> StatusBanner(
text = "IM 已断开${state.reason?.let { "$it" } ?: ""}",
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
@Composable
private fun StatusBanner(
text: String,
containerColor: androidx.compose.ui.graphics.Color,
contentColor: androidx.compose.ui.graphics.Color,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = containerColor,
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = contentColor,
style = MaterialTheme.typography.bodySmall,
)
}
}

查看文件

@ -41,6 +41,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -153,19 +154,23 @@ fun GroupSettingsScreen(
viewModel: GroupViewModel = viewModel(),
) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle()
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(group?.name ?: "群设置") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
androidx.compose.foundation.layout.Column {
TopAppBar(
title = { Text(group?.name ?: "群设置") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
},
)
)
ConnectionStatusBanner(connectionState)
}
},
) { padding ->
Column(

查看文件

@ -25,11 +25,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.sample.ui.contact.ContactScreen
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
import com.xuqm.sdk.sample.ui.group.GroupListScreen
import com.xuqm.sdk.sample.ui.profile.ProfileScreen
import com.xuqm.sdk.sample.ui.update.UpdateScreen
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
private data class BottomTab(val label: String, val icon: ImageVector)
@ -50,17 +53,21 @@ fun MainScreen(
onLogout: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(0) }
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(tabs[selectedTab].label) },
actions = {
IconButton(onClick = onOpenEnvironment) {
Icon(Icons.Default.Settings, contentDescription = "环境设置")
androidx.compose.foundation.layout.Column {
TopAppBar(
title = { Text(tabs[selectedTab].label) },
actions = {
IconButton(onClick = onOpenEnvironment) {
Icon(Icons.Default.Settings, contentDescription = "环境设置")
}
}
},
)
)
ConnectionStatusBanner(connectionState)
}
},
bottomBar = {
NavigationBar {

查看文件

@ -10,17 +10,35 @@ import com.xuqm.sdk.im.api.SetMutedRequest
import com.xuqm.sdk.im.api.SetPinnedRequest
import com.xuqm.sdk.im.api.UpdateGroupRequest
import com.xuqm.sdk.im.listener.ImEventListener
import com.xuqm.sdk.im.model.ImConnectionState
import com.xuqm.sdk.im.model.ConversationData
import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object ImSDK {
private var client: ImClient? = null
private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl)
private val connectionListener = object : ImEventListener {
override fun onConnected() {
_connectionState.value = ImConnectionState.Connected
}
override fun onDisconnected(reason: String?) {
_connectionState.value = ImConnectionState.Disconnected(reason)
}
override fun onError(error: String) {
_connectionState.value = ImConnectionState.Disconnected(error)
}
}
private val _connectionState = MutableStateFlow<ImConnectionState>(ImConnectionState.Disconnected("未连接"))
val connectionState: StateFlow<ImConnectionState> = _connectionState
var currentUserId: String = ""
private set
@ -131,7 +149,9 @@ object ImSDK {
private fun connectWithToken(token: String) {
XuqmSDK.tokenStore.saveToken(token)
client?.disconnect()
_connectionState.value = ImConnectionState.Connecting
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId)
client?.addListener(connectionListener)
client?.connect()
}
@ -139,6 +159,7 @@ object ImSDK {
client?.disconnect()
client = null
currentUserId = ""
_connectionState.value = ImConnectionState.Disconnected("已断开")
if (clearTokenStore) {
XuqmSDK.tokenStore.clear()
}

查看文件

@ -0,0 +1,7 @@
package com.xuqm.sdk.im.model
sealed interface ImConnectionState {
data object Connecting : ImConnectionState
data object Connected : ImConnectionState
data class Disconnected(val reason: String? = null) : ImConnectionState
}