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.XuqmSDK
import com.xuqm.sdk.core.LogLevel import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.sample.di.AppDependencies 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() { class XuqmSampleApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AppDependencies.init(this) AppDependencies.init(this)
@ -15,5 +21,8 @@ class XuqmSampleApp : Application() {
appId = "ak_demo_chat", appId = "ak_demo_chat",
logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN, 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 getCurrentUserId(): String? = prefs.getString("user_id", null)
fun getCurrentNickname(): String? = prefs.getString("nickname", null) fun getCurrentNickname(): String? = prefs.getString("nickname", null)
fun getCurrentAvatar(): String? = prefs.getString("avatar", null) fun getCurrentAvatar(): String? = prefs.getString("avatar", null)
fun getCurrentUserSig(): String? = prefs.getString("user_sig", null)
fun isLoggedIn(): Boolean = getDemoToken() != null fun isLoggedIn(): Boolean = getDemoToken() != null
private fun saveSession(result: AuthResult) { private fun saveSession(result: AuthResult) {
@ -113,6 +114,21 @@ class AuthRepository(context: Context) {
runCatching { api.searchUsers(keyword = keyword).data ?: emptyList() } 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() { fun logout() {
XuqmSDK.logout() XuqmSDK.logout()
prefs.edit().clear().apply() prefs.edit().clear().apply()

查看文件

@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -41,7 +42,10 @@ 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.model.ImMessage
import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.ui.InitialAvatar import com.xuqm.sdk.ui.InitialAvatar
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -54,31 +58,51 @@ fun ChatScreen(
viewModel: ChatViewModel = viewModel(), viewModel: ChatViewModel = viewModel(),
) { ) {
val messages by viewModel.messages.collectAsStateWithLifecycle() 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() val listState = rememberLazyListState()
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) }
LaunchedEffect(messages.size) { LaunchedEffect(scrollSignal) {
if (messages.isNotEmpty()) listState.animateScrollToItem(0) 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( Scaffold(
topBar = { topBar = {
TopAppBar( Column {
title = { Text(targetName) }, TopAppBar(
navigationIcon = { title = { Text(targetName) },
IconButton(onClick = onNavigateBack) { navigationIcon = {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) 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)
} }
} },
}, actions = {
) if (chatType == "GROUP" && onGroupSettings != null) {
IconButton(onClick = onGroupSettings) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
},
)
ConnectionStatusBanner(connectionState)
}
}, },
bottomBar = { bottomBar = {
Row( Row(
@ -118,6 +142,18 @@ fun ChatScreen(
isOwn = msg.fromId == viewModel.currentUserId, 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()) private val _messages = MutableStateFlow<List<ImMessage>>(emptyList())
val messages: StateFlow<List<ImMessage>> = _messages 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 val currentUserId: String get() = ImSDK.currentUserId
private lateinit var targetId: String private lateinit var targetId: String
private lateinit var chatType: String private lateinit var chatType: String
private var nextHistoryPage = 0
private var initialized = false
private val listener = object : ImEventListener { private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) { override fun onMessage(message: ImMessage) {
if (message.fromId == targetId || message.toId == targetId) { if (isRelevant(message)) {
_messages.value = listOf(message) + _messages.value prependMessage(message)
requestScrollToBottom()
} }
} }
override fun onGroupMessage(message: ImMessage) { override fun onGroupMessage(message: ImMessage) {
if (message.toId == targetId) { if (isRelevant(message)) {
_messages.value = listOf(message) + _messages.value prependMessage(message)
requestScrollToBottom()
} }
} }
} }
fun init(targetId: String, chatType: String) { fun init(targetId: String, chatType: String) {
if (initialized && this.targetId == targetId && this.chatType == chatType) return
this.targetId = targetId this.targetId = targetId
this.chatType = chatType this.chatType = chatType
nextHistoryPage = 0
initialized = true
_messages.value = emptyList()
_hasMoreHistory.value = true
_isLoadingMore.value = false
ImSDK.addListener(listener) ImSDK.addListener(listener)
loadHistory() loadInitialHistory()
viewModelScope.launch { viewModelScope.launch {
runCatching { ImSDK.markRead(targetId, chatType) } 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 { viewModelScope.launch {
val history = if (chatType == "GROUP") { _isLoadingMore.value = true
runCatching { ImSDK.fetchGroupHistory(targetId) }.getOrDefault(emptyList()) val page = if (replace) 0 else nextHistoryPage
} else { val history = fetchHistory(page)
runCatching { ImSDK.fetchHistory(targetId) }.getOrDefault(emptyList()) 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) 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() { override fun onCleared() {
ImSDK.removeListener(listener) 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -153,19 +154,23 @@ fun GroupSettingsScreen(
viewModel: GroupViewModel = viewModel(), viewModel: GroupViewModel = viewModel(),
) { ) {
val group by viewModel.currentGroup.collectAsStateWithLifecycle() val group by viewModel.currentGroup.collectAsStateWithLifecycle()
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) } LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( androidx.compose.foundation.layout.Column {
title = { Text(group?.name ?: "群设置") }, TopAppBar(
navigationIcon = { title = { Text(group?.name ?: "群设置") },
IconButton(onClick = onNavigateBack) { navigationIcon = {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
} }
}, )
) ConnectionStatusBanner(connectionState)
}
}, },
) { padding -> ) { padding ->
Column( Column(

查看文件

@ -25,11 +25,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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.contact.ContactScreen
import com.xuqm.sdk.sample.ui.conversation.ConversationScreen import com.xuqm.sdk.sample.ui.conversation.ConversationScreen
import com.xuqm.sdk.sample.ui.group.GroupListScreen import com.xuqm.sdk.sample.ui.group.GroupListScreen
import com.xuqm.sdk.sample.ui.profile.ProfileScreen import com.xuqm.sdk.sample.ui.profile.ProfileScreen
import com.xuqm.sdk.sample.ui.update.UpdateScreen 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) private data class BottomTab(val label: String, val icon: ImageVector)
@ -50,17 +53,21 @@ fun MainScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
) { ) {
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( androidx.compose.foundation.layout.Column {
title = { Text(tabs[selectedTab].label) }, TopAppBar(
actions = { title = { Text(tabs[selectedTab].label) },
IconButton(onClick = onOpenEnvironment) { actions = {
Icon(Icons.Default.Settings, contentDescription = "环境设置") IconButton(onClick = onOpenEnvironment) {
Icon(Icons.Default.Settings, contentDescription = "环境设置")
}
} }
}, )
) ConnectionStatusBanner(connectionState)
}
}, },
bottomBar = { bottomBar = {
NavigationBar { 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.SetPinnedRequest
import com.xuqm.sdk.im.api.UpdateGroupRequest import com.xuqm.sdk.im.api.UpdateGroupRequest
import com.xuqm.sdk.im.listener.ImEventListener 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.ConversationData
import com.xuqm.sdk.im.model.ImGroup import com.xuqm.sdk.im.model.ImGroup
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object ImSDK { object ImSDK {
private var client: ImClient? = null private var client: ImClient? = null
private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl) 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 = "" var currentUserId: String = ""
private set private set
@ -131,7 +149,9 @@ object ImSDK {
private fun connectWithToken(token: String) { private fun connectWithToken(token: String) {
XuqmSDK.tokenStore.saveToken(token) XuqmSDK.tokenStore.saveToken(token)
client?.disconnect() client?.disconnect()
_connectionState.value = ImConnectionState.Connecting
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId) client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId)
client?.addListener(connectionListener)
client?.connect() client?.connect()
} }
@ -139,6 +159,7 @@ object ImSDK {
client?.disconnect() client?.disconnect()
client = null client = null
currentUserId = "" currentUserId = ""
_connectionState.value = ImConnectionState.Disconnected("已断开")
if (clearTokenStore) { if (clearTokenStore) {
XuqmSDK.tokenStore.clear() 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
}