feat(chat): 添加聊天功能和用户认证仓库
- 新增AuthRepository用于用户认证和会话管理 - 实现聊天界面ChatScreen支持单聊和群聊 - 添加聊天视图模型ChatViewModel处理消息逻辑 - 创建群组管理界面GroupScreen支持群组操作 - 实现主界面MainScreen整合各功能模块 - 添加应用启动类XuqmSampleApp初始化配置 - 在ImSDK中增加连接状态管理功能
这个提交包含在:
父节点
c318a318a3
当前提交
64fefd1bbb
@ -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,16 +58,34 @@ 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 = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = { Text(targetName) },
|
||||
navigationIcon = {
|
||||
@ -79,6 +101,8 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
val history = if (chatType == "GROUP") {
|
||||
runCatching { ImSDK.fetchGroupHistory(targetId) }.getOrDefault(emptyList())
|
||||
} else {
|
||||
runCatching { ImSDK.fetchHistory(targetId) }.getOrDefault(emptyList())
|
||||
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 {
|
||||
_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
|
||||
}
|
||||
_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 = {
|
||||
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 = {
|
||||
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
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户