From 6dd0fa8f496ba8f571ee8ef431d55556144629b2 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Apr 2026 17:18:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E5=88=9D=E5=A7=8B=E5=8C=96=20Andr?= =?UTF-8?q?oid=20SDK=20=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 SDK 配置管理、网络请求客户端和令牌存储功能 - 实现即时通讯 IM 模块,包括消息收发、群组管理和会话功能 - 集成推送服务和应用更新功能模块 - 创建示例应用演示 SDK 使用方法 - 配置项目依赖管理和构建设置 --- build.gradle.kts | 2 + gradle/libs.versions.toml | 20 +- sample-app/build.gradle.kts | 33 ++- sample-app/src/main/AndroidManifest.xml | 12 +- .../java/com/xuqm/sdk/sample/MainActivity.kt | 154 ++----------- .../java/com/xuqm/sdk/sample/XuqmSampleApp.kt | 10 + .../com/xuqm/sdk/sample/data/api/DemoApi.kt | 46 ++++ .../sdk/sample/data/repo/AuthRepository.kt | 99 +++++++++ .../java/com/xuqm/sdk/sample/di/AppModule.kt | 18 ++ .../xuqm/sdk/sample/navigation/AppNavGraph.kt | 92 ++++++++ .../xuqm/sdk/sample/ui/auth/LoginScreen.kt | 108 +++++++++ .../xuqm/sdk/sample/ui/auth/LoginViewModel.kt | 35 +++ .../xuqm/sdk/sample/ui/auth/RegisterScreen.kt | 144 ++++++++++++ .../com/xuqm/sdk/sample/ui/chat/ChatScreen.kt | 194 ++++++++++++++++ .../xuqm/sdk/sample/ui/chat/ChatViewModel.kt | 67 ++++++ .../sdk/sample/ui/contact/ContactScreen.kt | 167 ++++++++++++++ .../ui/conversation/ConversationScreen.kt | 150 +++++++++++++ .../ui/conversation/ConversationViewModel.kt | 53 +++++ .../xuqm/sdk/sample/ui/group/GroupScreen.kt | 207 ++++++++++++++++++ .../sdk/sample/ui/group/GroupViewModel.kt | 65 ++++++ .../com/xuqm/sdk/sample/ui/main/MainScreen.kt | 83 +++++++ .../sdk/sample/ui/profile/ProfileScreen.kt | 151 +++++++++++++ .../com/xuqm/sdk/sample/ui/theme/Theme.kt | 17 ++ .../xuqm/sdk/sample/ui/update/UpdateScreen.kt | 120 ++++++++++ sample-app/src/main/res/values/themes.xml | 4 + sdk-core/build.gradle.kts | 2 +- .../src/main/java/com/xuqm/sdk/XuqmSDK.kt | 16 +- .../main/java/com/xuqm/sdk/auth/TokenStore.kt | 34 +-- .../main/java/com/xuqm/sdk/core/SDKConfig.kt | 12 +- .../java/com/xuqm/sdk/network/ApiClient.kt | 8 +- .../src/main/java/com/xuqm/sdk/im/ImClient.kt | 13 +- sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt | 109 +++++++-- .../main/java/com/xuqm/sdk/im/api/ImApi.kt | 129 +++++++++-- .../java/com/xuqm/sdk/im/model/ImMessage.kt | 41 +++- .../main/java/com/xuqm/sdk/push/PushSDK.kt | 21 +- .../java/com/xuqm/sdk/push/api/PushApi.kt | 26 ++- .../java/com/xuqm/sdk/update/UpdateSDK.kt | 19 +- 37 files changed, 2216 insertions(+), 265 deletions(-) create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/data/api/DemoApi.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/di/AppModule.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginViewModel.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/RegisterScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/theme/Theme.kt create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt create mode 100644 sample-app/src/main/res/values/themes.xml diff --git a/build.gradle.kts b/build.gradle.kts index 00e35e3..f0ade34 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.kapt) apply false + alias(libs.plugins.hilt.android) apply false } group = "com.xuqm" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b4e4099..ffb408c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,16 +16,21 @@ okhttp = "5.3.2" gson = "2.13.2" jserialization = "1.9.0" webkit = "1.14.0" -coil = "2.7.0" -sentryAndroid = "8.39.1" +coil = "3.1.0" junit4 = "4.13.2" androidxJunit = "1.3.0" espresso = "3.7.0" +hilt = "2.56.2" +navigationCompose = "2.9.0" +securityCrypto = "1.0.0" +hiltNavigationCompose = "1.2.0" +viewmodelCompose = "2.10.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodelCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -38,16 +43,21 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" } androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } -coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentryAndroid" } +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } junit4 = { group = "junit", name = "junit", version.ref = "junit4" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } @@ -77,3 +87,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts index e50820c..e3968d3 100644 --- a/sample-app/build.gradle.kts +++ b/sample-app/build.gradle.kts @@ -1,8 +1,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) - - id("io.sentry.android.gradle") version "6.4.0" + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt.android) } android { @@ -10,7 +10,7 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { - applicationId = "com.xuqm.sdk.sample" + applicationId = "com.xuqm.demo" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 @@ -25,10 +25,16 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { jvmTarget = "21" } + + buildFeatures { + compose = true + buildConfig = true } - buildFeatures { compose = true } } dependencies { @@ -36,10 +42,21 @@ dependencies { implementation(project(":sdk-im")) implementation(project(":sdk-push")) implementation(project(":sdk-update")) - implementation(libs.sentry.android) + implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) implementation(libs.androidx.activity.compose) - implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(libs.androidx.navigation.compose) + + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + debugImplementation(libs.bundles.compose.debug) } diff --git a/sample-app/src/main/AndroidManifest.xml b/sample-app/src/main/AndroidManifest.xml index 246834e..9e6d2e5 100644 --- a/sample-app/src/main/AndroidManifest.xml +++ b/sample-app/src/main/AndroidManifest.xml @@ -5,13 +5,14 @@ + android:allowBackup="true" + android:label="XuqmGroup Demo" + android:theme="@style/Theme.XuqmDemo"> + android:exported="true" + android:windowSoftInputMode="adjustResize"> @@ -26,6 +27,5 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> - - + diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt index 98b061b..a6ec023 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/MainActivity.kt @@ -3,151 +3,25 @@ package com.xuqm.sdk.sample import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.xuqm.sdk.XuqmSDK -import com.xuqm.sdk.im.ImSDK -import com.xuqm.sdk.im.listener.ImEventListener -import com.xuqm.sdk.im.model.ChatType -import com.xuqm.sdk.im.model.ImMessage -import com.xuqm.sdk.im.model.MsgType -import com.xuqm.sdk.update.UpdateSDK -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import androidx.activity.enableEdgeToEdge +import com.xuqm.sdk.sample.data.repo.AuthRepository +import com.xuqm.sdk.sample.navigation.AppNavGraph +import com.xuqm.sdk.sample.ui.theme.XuqmTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@AndroidEntryPoint class MainActivity : ComponentActivity() { + + @Inject + lateinit var authRepository: AuthRepository + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - XuqmSDK.init( - context = this, - appKey = "ak_your_app_key", - appSecret = "your_app_secret", - apiBaseUrl = "http://10.0.2.2:8082", - imBaseUrl = "ws://10.0.2.2:8082/ws/im", - debug = true, - ) - + enableEdgeToEdge() setContent { - MaterialTheme { - Surface(modifier = Modifier.fillMaxSize()) { - SdkDemoScreen() - } - } - } - } -} - -@Composable -fun SdkDemoScreen() { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val messages = remember { mutableStateListOf() } - var msgInput by remember { mutableStateOf("") } - var userId by remember { mutableStateOf("user_001") } - var connected by remember { mutableStateOf(false) } - var updateInfo by remember { mutableStateOf("") } - - val listener = remember { - object : ImEventListener { - override fun onConnected() { messages.add("[IM] 已连接"); connected = true } - override fun onDisconnected(reason: String?) { messages.add("[IM] 断开: $reason"); connected = false } - override fun onMessage(message: ImMessage) { messages.add("[消息] ${message.fromUserId}: ${message.content}") } - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text("XuqmSDK Demo", style = MaterialTheme.typography.headlineSmall) - - Card { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("IM 测试", style = MaterialTheme.typography.titleMedium) - OutlinedTextField(value = userId, onValueChange = { userId = it }, label = { Text("UserId") }, modifier = Modifier.fillMaxWidth()) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - ImSDK.addListener(listener) - ImSDK.login("your_app_id", userId) - }) { Text("连接") } - Button(onClick = { ImSDK.disconnect(); connected = false }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { - Text("断开") - } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(value = msgInput, onValueChange = { msgInput = it }, - label = { Text("消息内容") }, modifier = Modifier.weight(1f)) - Button(onClick = { - if (msgInput.isNotBlank()) { - ImSDK.sendMessage("user_002", ChatType.SINGLE, MsgType.TEXT, msgInput) - msgInput = "" - } - }) { Text("发送") } - } - } - } - - Card { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("版本更新", style = MaterialTheme.typography.titleMedium) - Button(onClick = { - scope.launch { - val info = UpdateSDK.checkUpdate(context, "your_app_id") - updateInfo = if (info?.needsUpdate == true) - "发现新版本: ${info.versionName}" else "已是最新版本" - } - }) { Text("检查更新") } - if (updateInfo.isNotBlank()) Text(updateInfo) - } - } - - Card { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Sentry 测试", style = MaterialTheme.typography.titleMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - scope.launch { - val result = withContext(Dispatchers.IO) { - val eventId = Sentry.captureException( - IllegalStateException("This app uses Sentry! :)") - ) { eventScope -> - eventScope.setTag("source", "main_activity_button") - eventScope.setLevel(SentryLevel.ERROR) - } - val flushed = Sentry.flush(5_000) - eventId to flushed - } - messages.add("[Sentry] 已发送: ${result.first}, flush=${result.second}") - } - }) { Text("上报异常") } - - Button(onClick = { - throw RuntimeException("Uncaught crash test for Sentry") - }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { - Text("闪退测试") - } - } - } - } - - Card { - Column(Modifier.padding(12.dp)) { - Text("消息日志", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(8.dp)) - messages.forEach { msg -> Text(msg, style = MaterialTheme.typography.bodySmall) } + XuqmTheme { + AppNavGraph(authRepository = authRepository) } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt index 47c541c..837744a 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/XuqmSampleApp.kt @@ -1,9 +1,19 @@ package com.xuqm.sdk.sample import android.app.Application +import com.xuqm.sdk.XuqmSDK +import com.xuqm.sdk.core.LogLevel +import dagger.hilt.android.HiltAndroidApp +@HiltAndroidApp class XuqmSampleApp : Application() { + override fun onCreate() { super.onCreate() + XuqmSDK.initialize( + context = this, + appId = "your_app_id", + logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARN, + ) } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/api/DemoApi.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/api/DemoApi.kt new file mode 100644 index 0000000..da860de --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/api/DemoApi.kt @@ -0,0 +1,46 @@ +package com.xuqm.sdk.sample.data.api + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Query + +data class DemoResponse( + val code: Int = 0, + val message: String? = null, + val data: T? = null, +) + +data class LoginRequest(val userId: String, val password: String) + +data class RegisterRequest(val userId: String, val password: String, val nickname: String) + +data class LoginData(val token: String, val userId: String, val nickname: String, val avatar: String?) + +data class UserData(val userId: String, val nickname: String, val avatar: String?) + +data class UpdateProfileRequest(val nickname: String, val avatar: String?) + +data class ResetPasswordRequest(val oldPassword: String, val newPassword: String) + +interface DemoApi { + + @POST("api/user/login") + suspend fun login(@Body request: LoginRequest): DemoResponse + + @POST("api/user/register") + suspend fun register(@Body request: RegisterRequest): DemoResponse + + @GET("api/user/profile") + suspend fun getProfile(): DemoResponse + + @PUT("api/user/profile") + suspend fun updateProfile(@Body request: UpdateProfileRequest): DemoResponse + + @POST("api/user/reset-password") + suspend fun resetPassword(@Body request: ResetPasswordRequest): DemoResponse + + @GET("api/user/users") + suspend fun searchUsers(@Query("keyword") keyword: String): DemoResponse> +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt new file mode 100644 index 0000000..d4aae4c --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/AuthRepository.kt @@ -0,0 +1,99 @@ +package com.xuqm.sdk.sample.data.repo + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.xuqm.sdk.im.ImSDK +import com.xuqm.sdk.sample.data.api.DemoApi +import com.xuqm.sdk.sample.data.api.LoginRequest +import com.xuqm.sdk.sample.data.api.RegisterRequest +import com.xuqm.sdk.sample.data.api.ResetPasswordRequest +import com.xuqm.sdk.sample.data.api.UpdateProfileRequest +import com.xuqm.sdk.sample.data.api.UserData +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DemoApi, +) { + private val prefs = EncryptedSharedPreferences.create( + context, + "xuqm_demo_auth", + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun getDemoToken(): String? = prefs.getString("demo_token", null) + fun getCurrentUserId(): String? = prefs.getString("user_id", null) + fun getCurrentNickname(): String? = prefs.getString("nickname", null) + fun getCurrentAvatar(): String? = prefs.getString("avatar", null) + fun isLoggedIn(): Boolean = getDemoToken() != null + + private fun saveSession(token: String, userId: String, nickname: String, avatar: String?) { + prefs.edit() + .putString("demo_token", token) + .putString("user_id", userId) + .putString("nickname", nickname) + .putString("avatar", avatar) + .apply() + } + + suspend fun login(userId: String, password: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val res = api.login(LoginRequest(userId, password)) + val data = requireNotNull(res.data) { res.message ?: "Login failed" } + saveSession(data.token, data.userId, data.nickname, data.avatar) + ImSDK.login(data.userId, data.nickname, data.avatar) + UserData(data.userId, data.nickname, data.avatar) + } + } + + suspend fun register(userId: String, password: String, nickname: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val res = api.register(RegisterRequest(userId, password, nickname)) + val data = requireNotNull(res.data) { res.message ?: "Register failed" } + saveSession(data.token, data.userId, data.nickname, data.avatar) + ImSDK.login(data.userId, data.nickname, data.avatar) + UserData(data.userId, data.nickname, data.avatar) + } + } + + suspend fun getProfile(): Result = + withContext(Dispatchers.IO) { + runCatching { + requireNotNull(api.getProfile().data) { "Failed to get profile" } + } + } + + suspend fun updateProfile(nickname: String, avatar: String?): Result = + withContext(Dispatchers.IO) { + runCatching { + val data = requireNotNull(api.updateProfile(UpdateProfileRequest(nickname, avatar)).data) + prefs.edit().putString("nickname", data.nickname).putString("avatar", data.avatar).apply() + data + } + } + + suspend fun resetPassword(oldPassword: String, newPassword: String): Result = + withContext(Dispatchers.IO) { + runCatching { api.resetPassword(ResetPasswordRequest(oldPassword, newPassword)) } + } + + suspend fun searchUsers(keyword: String): Result> = + withContext(Dispatchers.IO) { + runCatching { api.searchUsers(keyword).data ?: emptyList() } + } + + fun logout() { + ImSDK.disconnect() + prefs.edit().clear().apply() + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppModule.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppModule.kt new file mode 100644 index 0000000..1a682ae --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppModule.kt @@ -0,0 +1,18 @@ +package com.xuqm.sdk.sample.di + +import com.xuqm.sdk.network.ApiClient +import com.xuqm.sdk.sample.data.api.DemoApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideDemoApi(): DemoApi = ApiClient.create() +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt new file mode 100644 index 0000000..f0e8fdc --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/navigation/AppNavGraph.kt @@ -0,0 +1,92 @@ +package com.xuqm.sdk.sample.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import com.xuqm.sdk.sample.data.repo.AuthRepository +import com.xuqm.sdk.sample.ui.auth.LoginScreen +import com.xuqm.sdk.sample.ui.auth.RegisterScreen +import com.xuqm.sdk.sample.ui.chat.ChatScreen +import com.xuqm.sdk.sample.ui.group.GroupSettingsScreen +import com.xuqm.sdk.sample.ui.main.MainScreen +import java.net.URLDecoder +import java.net.URLEncoder + +@Composable +fun AppNavGraph( + authRepository: AuthRepository, + navController: NavHostController = rememberNavController(), +) { + val startDestination = if (authRepository.isLoggedIn()) "main" else "auth" + + NavHost(navController = navController, startDestination = startDestination) { + + navigation(startDestination = "login", route = "auth") { + composable("login") { + LoginScreen( + onLoginSuccess = { + navController.navigate("main") { + popUpTo("auth") { inclusive = true } + } + }, + onNavigateToRegister = { navController.navigate("register") }, + ) + } + composable("register") { + RegisterScreen( + onRegisterSuccess = { + navController.navigate("main") { + popUpTo("auth") { inclusive = true } + } + }, + onNavigateBack = { navController.popBackStack() }, + ) + } + } + + composable("main") { + MainScreen( + onOpenChat = { targetId, chatType, targetName -> + val encodedName = URLEncoder.encode(targetName, "UTF-8") + navController.navigate("chat/$chatType/$targetId/$encodedName") + }, + onGroupSettings = { groupId -> + navController.navigate("group_settings/$groupId") + }, + onLogout = { + navController.navigate("auth") { + popUpTo("main") { inclusive = true } + } + }, + ) + } + + composable("chat/{chatType}/{targetId}/{targetName}") { backStackEntry -> + val chatType = backStackEntry.arguments?.getString("chatType") ?: "SINGLE" + val targetId = backStackEntry.arguments?.getString("targetId") ?: "" + val targetName = URLDecoder.decode( + backStackEntry.arguments?.getString("targetName") ?: targetId, "UTF-8" + ) + ChatScreen( + targetId = targetId, + chatType = chatType, + targetName = targetName, + onNavigateBack = { navController.popBackStack() }, + onGroupSettings = if (chatType == "GROUP") ({ + navController.navigate("group_settings/$targetId") + }) else null, + ) + } + + composable("group_settings/{groupId}") { backStackEntry -> + val groupId = backStackEntry.arguments?.getString("groupId") ?: "" + GroupSettingsScreen( + groupId = groupId, + onNavigateBack = { navController.popBackStack() }, + ) + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..61f18d7 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginScreen.kt @@ -0,0 +1,108 @@ +package com.xuqm.sdk.sample.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + onNavigateToRegister: () -> Unit, + viewModel: LoginViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + var userId by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + LaunchedEffect(state) { + if (state is LoginState.Success) onLoginSuccess() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .imePadding(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("XuqmGroup IM", style = MaterialTheme.typography.headlineMedium) + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = userId, + onValueChange = { userId = it }, + label = { Text("用户 ID") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("密码") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + + if (state is LoginState.Error) { + Spacer(Modifier.height(8.dp)) + Text( + (state as LoginState.Error).message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = { viewModel.login(userId.trim(), password) }, + modifier = Modifier.fillMaxWidth(), + enabled = state !is LoginState.Loading && userId.isNotBlank() && password.isNotBlank(), + ) { + if (state is LoginState.Loading) { + CircularProgressIndicator(modifier = Modifier.height(20.dp)) + } else { + Text("登录") + } + } + + Spacer(Modifier.height(12.dp)) + + TextButton(onClick = onNavigateToRegister) { + Text("没有账号?注册") + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginViewModel.kt new file mode 100644 index 0000000..c8bf9e4 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/LoginViewModel.kt @@ -0,0 +1,35 @@ +package com.xuqm.sdk.sample.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.sample.data.repo.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface LoginState { + data object Idle : LoginState + data object Loading : LoginState + data object Success : LoginState + data class Error(val message: String) : LoginState +} + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(LoginState.Idle) + val state: StateFlow = _state + + fun login(userId: String, password: String) { + viewModelScope.launch { + _state.value = LoginState.Loading + authRepository.login(userId, password) + .onSuccess { _state.value = LoginState.Success } + .onFailure { _state.value = LoginState.Error(it.message ?: "Unknown error") } + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/RegisterScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/RegisterScreen.kt new file mode 100644 index 0000000..0a74fa6 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/auth/RegisterScreen.kt @@ -0,0 +1,144 @@ +package com.xuqm.sdk.sample.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.sample.data.repo.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RegisterViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(LoginState.Idle) + val state: StateFlow = _state + + fun register(userId: String, password: String, nickname: String) { + viewModelScope.launch { + _state.value = LoginState.Loading + authRepository.register(userId, password, nickname) + .onSuccess { _state.value = LoginState.Success } + .onFailure { _state.value = LoginState.Error(it.message ?: "Registration failed") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RegisterScreen( + onRegisterSuccess: () -> Unit, + onNavigateBack: () -> Unit, + viewModel: RegisterViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + var userId by remember { mutableStateOf("") } + var nickname by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + LaunchedEffect(state) { + if (state is LoginState.Success) onRegisterSuccess() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("注册") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = userId, + onValueChange = { userId = it }, + label = { Text("用户 ID") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it }, + label = { Text("昵称") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("密码") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + + if (state is LoginState.Error) { + Text( + (state as LoginState.Error).message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Button( + onClick = { viewModel.register(userId.trim(), password, nickname.trim()) }, + modifier = Modifier.fillMaxWidth(), + enabled = state !is LoginState.Loading + && userId.isNotBlank() && password.isNotBlank() && nickname.isNotBlank(), + ) { + if (state is LoginState.Loading) CircularProgressIndicator(Modifier.height(20.dp)) + else Text("注册") + } + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..3c2af83 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt @@ -0,0 +1,194 @@ +package com.xuqm.sdk.sample.ui.chat + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xuqm.sdk.im.model.ImMessage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + targetId: String, + chatType: String, + targetName: String, + onNavigateBack: () -> Unit, + onGroupSettings: (() -> Unit)? = null, + viewModel: ChatViewModel = hiltViewModel(), +) { + val messages by viewModel.messages.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + var input by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) listState.animateScrollToItem(0) + } + + 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) + } + } + }, + ) + }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("输入消息…") }, + maxLines = 4, + shape = RoundedCornerShape(24.dp), + ) + IconButton( + onClick = { viewModel.sendText(input); input = "" }, + enabled = input.isNotBlank(), + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) + } + } + }, + ) { padding -> + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + items(messages, key = { it.id }) { msg -> + MessageBubble( + message = msg, + isOwn = msg.fromId == viewModel.currentUserId, + ) + } + } + } +} + +@Composable +private fun MessageBubble(message: ImMessage, isOwn: Boolean) { + val arrangement = if (isOwn) Arrangement.End else Arrangement.Start + val bubbleColor = if (isOwn) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = arrangement, + verticalAlignment = Alignment.Bottom, + ) { + if (!isOwn) { + AvatarPlaceholder(message.fromId) + } + + Surface( + shape = RoundedCornerShape( + topStart = if (isOwn) 16.dp else 4.dp, + topEnd = if (isOwn) 4.dp else 16.dp, + bottomStart = 16.dp, + bottomEnd = 16.dp, + ), + color = bubbleColor, + modifier = Modifier + .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, + ) + } + + if (isOwn) { + AvatarPlaceholder(message.fromId) + } + } +} + +@Composable +private fun AvatarPlaceholder(userId: String) { + Surface( + modifier = Modifier.size(32.dp).clip(CircleShape), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Text(userId.take(1).uppercase(), style = MaterialTheme.typography.labelSmall) + } + } +} + +private fun parseContent(message: ImMessage): String { + return when (message.msgType) { + "TEXT" -> runCatching { + org.json.JSONObject(message.content).getString("text") + }.getOrDefault(message.content) + "IMAGE" -> "[图片]" + "AUDIO" -> "[语音]" + "VIDEO" -> "[视频]" + "FILE" -> "[文件]" + "REVOKED" -> "[消息已撤回]" + "NOTIFY" -> runCatching { + org.json.JSONObject(message.content).getString("content") + }.getOrDefault("[通知]") + else -> message.content + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..a08744c --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt @@ -0,0 +1,67 @@ +package com.xuqm.sdk.sample.ui.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.im.ImSDK +import com.xuqm.sdk.im.listener.ImEventListener +import com.xuqm.sdk.im.model.ImMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ChatViewModel @Inject constructor() : ViewModel() { + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + val currentUserId: String get() = ImSDK.currentUserId + + private lateinit var targetId: String + private lateinit var chatType: String + + private val listener = object : ImEventListener { + override fun onMessage(message: ImMessage) { + if (message.fromId == targetId || message.toId == targetId) { + _messages.value = listOf(message) + _messages.value + } + } + override fun onGroupMessage(message: ImMessage) { + if (message.toId == targetId) { + _messages.value = listOf(message) + _messages.value + } + } + } + + fun init(targetId: String, chatType: String) { + this.targetId = targetId + this.chatType = chatType + ImSDK.addListener(listener) + loadHistory() + 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()) + } + _messages.value = history + } + } + + fun sendText(content: String) { + if (content.isBlank()) return + ImSDK.sendMessage(targetId, chatType, "TEXT", content) + } + + override fun onCleared() { + ImSDK.removeListener(listener) + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt new file mode 100644 index 0000000..b5ba098 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt @@ -0,0 +1,167 @@ +package com.xuqm.sdk.sample.ui.contact + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.im.ImSDK +import com.xuqm.sdk.im.model.UserProfile +import com.xuqm.sdk.sample.data.api.UserData +import com.xuqm.sdk.sample.data.repo.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ContactViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _friends = MutableStateFlow>(emptyList()) + val friends: StateFlow> = _friends + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults: StateFlow> = _searchResults + + init { loadFriends() } + + fun loadFriends() { + viewModelScope.launch { + runCatching { ImSDK.listFriends() } + .onSuccess { _friends.value = it } + } + } + + fun search(keyword: String) { + if (keyword.isBlank()) { _searchResults.value = emptyList(); return } + viewModelScope.launch { + authRepository.searchUsers(keyword) + .onSuccess { _searchResults.value = it } + } + } + + fun addFriend(userId: String) { + viewModelScope.launch { + runCatching { ImSDK.addFriend(userId) } + .onSuccess { loadFriends() } + } + } + + fun removeFriend(userId: String) { + viewModelScope.launch { + runCatching { ImSDK.removeFriend(userId) } + .onSuccess { loadFriends() } + } + } +} + +@Composable +fun ContactScreen( + onOpenChat: (userId: String) -> Unit, + viewModel: ContactViewModel = hiltViewModel(), +) { + val friends by viewModel.friends.collectAsStateWithLifecycle() + val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() + var keyword by remember { mutableStateOf("") } + + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = keyword, + onValueChange = { keyword = it; viewModel.search(it) }, + modifier = Modifier.fillMaxWidth().padding(12.dp), + placeholder = { Text("搜索用户 ID 或昵称") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + ) + + if (keyword.isBlank()) { + Text( + "联系人(${friends.size})", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.outline, + ) + LazyColumn { + items(friends, key = { it.userId }) { user -> + FriendItem( + userId = user.userId, + nickname = user.nickname, + onChat = { onOpenChat(user.userId) }, + onRemove = { viewModel.removeFriend(user.userId) }, + ) + HorizontalDivider() + } + } + } else { + LazyColumn { + items(searchResults, key = { it.userId }) { user -> + val isFriend = friends.any { it.userId == user.userId } + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(user.nickname, style = MaterialTheme.typography.titleSmall) + Text(user.userId, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline) + } + if (!isFriend) { + TextButton(onClick = { viewModel.addFriend(user.userId) }) { Text("添加好友") } + } else { + TextButton(onClick = { onOpenChat(user.userId) }) { Text("发消息") } + } + } + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun FriendItem(userId: String, nickname: String, onChat: () -> Unit, onRemove: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onChat) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(nickname, style = MaterialTheme.typography.titleSmall) + Text(userId, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline) + } + TextButton(onClick = onRemove) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt new file mode 100644 index 0000000..84219a0 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt @@ -0,0 +1,150 @@ +package com.xuqm.sdk.sample.ui.conversation + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Badge +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.xuqm.sdk.im.model.ConversationData +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun ConversationScreen( + onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit, + viewModel: ConversationViewModel = hiltViewModel(), +) { + val conversations by viewModel.conversations.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(conversations, key = { "${it.chatType}_${it.targetId}" }) { conv -> + ConversationItem( + conversation = conv, + onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) }, + onPinToggle = { + scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) } + }, + onMuteToggle = { + scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) } + }, + ) + HorizontalDivider(modifier = Modifier.padding(start = 72.dp)) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConversationItem( + conversation: ConversationData, + onClick: () -> Unit, + onPinToggle: () -> Unit, + onMuteToggle: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + Box { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = { showMenu = true }) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(48.dp).clip(CircleShape), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + conversation.targetId.take(1).uppercase(), + style = MaterialTheme.typography.titleMedium, + ) + } + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + conversation.targetId, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (conversation.isPinned) { + Text("置顶", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary) + } + Text( + formatTime(conversation.lastMsgTime), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + conversation.lastMsgContent ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (conversation.unreadCount > 0 && !conversation.isMuted) { + Badge { Text("${conversation.unreadCount}") } + } + } + } + } + + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text(if (conversation.isPinned) "取消置顶" else "置顶") }, + onClick = { showMenu = false; onPinToggle() }, + ) + DropdownMenuItem( + text = { Text(if (conversation.isMuted) "取消免打扰" else "免打扰") }, + onClick = { showMenu = false; onMuteToggle() }, + ) + } + } +} + +private fun formatTime(timestamp: Long): String { + if (timestamp == 0L) return "" + return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt new file mode 100644 index 0000000..08f1838 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt @@ -0,0 +1,53 @@ +package com.xuqm.sdk.sample.ui.conversation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.im.ImSDK +import com.xuqm.sdk.im.model.ConversationData +import com.xuqm.sdk.im.model.ImMessage +import com.xuqm.sdk.im.listener.ImEventListener +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ConversationViewModel @Inject constructor() : ViewModel() { + + private val _conversations = MutableStateFlow>(emptyList()) + val conversations: StateFlow> = _conversations + + private val listener = object : ImEventListener { + override fun onMessage(message: ImMessage) { refresh() } + override fun onGroupMessage(message: ImMessage) { refresh() } + } + + init { + ImSDK.addListener(listener) + refresh() + } + + fun refresh() { + viewModelScope.launch { + runCatching { ImSDK.listConversations() } + .onSuccess { list -> + _conversations.value = list.sortedByDescending { it.lastMsgTime } + } + } + } + + suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) { + runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) } + refresh() + } + + suspend fun setMuted(targetId: String, chatType: String, muted: Boolean) { + runCatching { ImSDK.setConversationMuted(targetId, chatType, muted) } + refresh() + } + + override fun onCleared() { + ImSDK.removeListener(listener) + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt new file mode 100644 index 0000000..4a9569c --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt @@ -0,0 +1,207 @@ +package com.xuqm.sdk.sample.ui.group + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.xuqm.sdk.im.ImSDK +import com.xuqm.sdk.im.model.ImGroup + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupListScreen( + onOpenGroupChat: (groupId: String, groupName: String) -> Unit, + onGroupSettings: (groupId: String) -> Unit, + viewModel: GroupViewModel = hiltViewModel(), +) { + val groups by viewModel.groups.collectAsStateWithLifecycle() + var showCreateDialog by remember { mutableStateOf(false) } + + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, contentDescription = null) + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + ) { + items(groups, key = { it.id }) { group -> + GroupItem( + group = group, + onClick = { onOpenGroupChat(group.id, group.name) }, + onSettings = { onGroupSettings(group.id) }, + ) + HorizontalDivider() + } + } + } + + if (showCreateDialog) { + CreateGroupDialog( + onDismiss = { showCreateDialog = false }, + onCreate = { name, memberIds -> + showCreateDialog = false + viewModel.createGroup(name, memberIds) { group -> + onOpenGroupChat(group.id, group.name) + } + }, + ) + } +} + +@Composable +private fun GroupItem(group: ImGroup, onClick: () -> Unit, onSettings: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(group.name, style = MaterialTheme.typography.titleSmall) + Text( + "${group.memberIds.split(",").size} 位成员", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + TextButton(onClick = onSettings) { Text("设置") } + } +} + +@Composable +private fun CreateGroupDialog(onDismiss: () -> Unit, onCreate: (String, List) -> Unit) { + var name by remember { mutableStateOf("") } + var memberInput by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("创建群组") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("群名称") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = memberInput, + onValueChange = { memberInput = it }, + label = { Text("成员 ID(逗号分隔)") }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + val members = memberInput.split(",").map { it.trim() }.filter { it.isNotBlank() } + val allMembers = (members + listOf(ImSDK.currentUserId)).distinct() + onCreate(name.trim(), allMembers) + }, + enabled = name.isNotBlank(), + ) { Text("创建") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupSettingsScreen( + groupId: String, + onNavigateBack: () -> Unit, + viewModel: GroupViewModel = hiltViewModel(), +) { + val group by viewModel.currentGroup.collectAsStateWithLifecycle() + + LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(group?.name ?: "群设置") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + ) { + group?.let { g -> + Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline) + Spacer(Modifier.height(8.dp)) + Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(16.dp)) + Text("成员", style = MaterialTheme.typography.titleSmall) + val memberIds = g.memberIds.split(",").filter { it.isNotBlank() } + memberIds.forEach { memberId -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(memberId, modifier = Modifier.weight(1f)) + if (g.creatorId == ImSDK.currentUserId && memberId != ImSDK.currentUserId) { + TextButton(onClick = { viewModel.removeMember(g.id, memberId) }) { + Text("移除", color = MaterialTheme.colorScheme.error) + } + } + } + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = { viewModel.leaveGroup(g.id) { onNavigateBack() } }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { Text("退出群聊") } + } + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt new file mode 100644 index 0000000..8c1afa5 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupViewModel.kt @@ -0,0 +1,65 @@ +package com.xuqm.sdk.sample.ui.group + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.im.ImSDK +import com.xuqm.sdk.im.model.ImGroup +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GroupViewModel @Inject constructor() : ViewModel() { + + private val _groups = MutableStateFlow>(emptyList()) + val groups: StateFlow> = _groups + + private val _currentGroup = MutableStateFlow(null) + val currentGroup: StateFlow = _currentGroup + + init { loadGroups() } + + fun loadGroups() { + viewModelScope.launch { + runCatching { ImSDK.listGroups() } + .onSuccess { _groups.value = it } + } + } + + fun loadGroupInfo(groupId: String) { + viewModelScope.launch { + runCatching { ImSDK.getGroupInfo(groupId) } + .onSuccess { _currentGroup.value = it } + } + } + + fun createGroup(name: String, memberIds: List, onSuccess: (ImGroup) -> Unit) { + viewModelScope.launch { + runCatching { ImSDK.createGroup(name, memberIds) } + .onSuccess { group -> group?.let { onSuccess(it); loadGroups() } } + } + } + + fun updateGroup(groupId: String, name: String?, announcement: String?) { + viewModelScope.launch { + runCatching { ImSDK.updateGroupInfo(groupId, name, announcement) } + .onSuccess { loadGroupInfo(groupId) } + } + } + + fun removeMember(groupId: String, userId: String) { + viewModelScope.launch { + runCatching { ImSDK.removeGroupMember(groupId, userId) } + .onSuccess { loadGroupInfo(groupId) } + } + } + + fun leaveGroup(groupId: String, onSuccess: () -> Unit) { + viewModelScope.launch { + runCatching { ImSDK.leaveGroup(groupId) } + .onSuccess { loadGroups(); onSuccess() } + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt new file mode 100644 index 0000000..f31348d --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/main/MainScreen.kt @@ -0,0 +1,83 @@ +package com.xuqm.sdk.sample.ui.main + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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 + +private data class BottomTab(val label: String, val icon: ImageVector) + +private val tabs = listOf( + BottomTab("消息", Icons.Default.ChatBubble), + BottomTab("群组", Icons.Default.Group), + BottomTab("联系人", Icons.Default.People), + BottomTab("更新", Icons.Default.SystemUpdate), + BottomTab("我", Icons.Default.Person), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit, + onGroupSettings: (groupId: String) -> Unit, + onLogout: () -> Unit, +) { + var selectedTab by remember { mutableIntStateOf(0) } + + Scaffold( + topBar = { + TopAppBar(title = { Text(tabs[selectedTab].label) }) + }, + bottomBar = { + NavigationBar { + tabs.forEachIndexed { index, tab -> + NavigationBarItem( + selected = selectedTab == index, + onClick = { selectedTab = index }, + icon = { Icon(tab.icon, contentDescription = null) }, + label = { Text(tab.label) }, + ) + } + } + }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when (selectedTab) { + 0 -> ConversationScreen(onOpenChat = onOpenChat) + 1 -> GroupListScreen( + onOpenGroupChat = { groupId, groupName -> + onOpenChat(groupId, "GROUP", groupName) + }, + onGroupSettings = onGroupSettings, + ) + 2 -> ContactScreen(onOpenChat = { userId -> onOpenChat(userId, "SINGLE", userId) }) + 3 -> UpdateScreen() + 4 -> ProfileScreen(onLogout = onLogout) + } + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..592b8cf --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/profile/ProfileScreen.kt @@ -0,0 +1,151 @@ +package com.xuqm.sdk.sample.ui.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.sample.data.repo.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ProfileUiState( + val userId: String = "", + val nickname: String = "", + val avatar: String? = null, + val isSaving: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _state = MutableStateFlow( + ProfileUiState( + userId = authRepository.getCurrentUserId() ?: "", + nickname = authRepository.getCurrentNickname() ?: "", + avatar = authRepository.getCurrentAvatar(), + ) + ) + val state: StateFlow = _state + + fun updateNickname(nickname: String) { + _state.value = _state.value.copy(nickname = nickname) + } + + fun save() { + viewModelScope.launch { + _state.value = _state.value.copy(isSaving = true, error = null) + authRepository.updateProfile(_state.value.nickname, _state.value.avatar) + .onSuccess { _state.value = _state.value.copy(isSaving = false) } + .onFailure { _state.value = _state.value.copy(isSaving = false, error = it.message) } + } + } + + fun logout(onLoggedOut: () -> Unit) { + authRepository.logout() + onLoggedOut() + } +} + +@Composable +fun ProfileScreen( + onLogout: () -> Unit, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(Modifier.height(16.dp)) + + Surface( + modifier = Modifier.size(80.dp).clip(CircleShape), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + state.nickname.take(1).uppercase(), + style = MaterialTheme.typography.headlineMedium, + ) + } + } + + Text(state.userId, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline) + + HorizontalDivider() + + OutlinedTextField( + value = state.nickname, + onValueChange = viewModel::updateNickname, + label = { Text("昵称") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (state.error != null) { + Text(state.error!!, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + + Button( + onClick = viewModel::save, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isSaving && state.nickname.isNotBlank(), + ) { + if (state.isSaving) CircularProgressIndicator(Modifier.size(20.dp)) + else Text("保存") + } + + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + onClick = { viewModel.logout(onLogout) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("退出登录") + } + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/theme/Theme.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/theme/Theme.kt new file mode 100644 index 0000000..ea2147f --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/theme/Theme.kt @@ -0,0 +1,17 @@ +package com.xuqm.sdk.sample.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun XuqmTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = lightColorScheme(), + content = content, + ) +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt new file mode 100644 index 0000000..8440366 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/update/UpdateScreen.kt @@ -0,0 +1,120 @@ +package com.xuqm.sdk.sample.ui.update + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.xuqm.sdk.update.UpdateSDK +import com.xuqm.sdk.update.model.UpdateInfo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class UpdateUiState( + val isChecking: Boolean = false, + val appUpdate: UpdateInfo? = null, + val downloadProgress: Int = -1, + val message: String? = null, +) + +@HiltViewModel +class UpdateViewModel @Inject constructor() : ViewModel() { + + private val _state = MutableStateFlow(UpdateUiState()) + val state: StateFlow = _state + + fun checkAppUpdate(context: Context) { + viewModelScope.launch { + _state.value = _state.value.copy(isChecking = true, message = null) + val info = UpdateSDK.checkAppUpdate(context) + _state.value = _state.value.copy( + isChecking = false, + appUpdate = info, + message = if (info?.needsUpdate == true) null else "已是最新版本", + ) + } + } + + fun downloadAndInstall(context: Context, downloadUrl: String) { + viewModelScope.launch { + _state.value = _state.value.copy(downloadProgress = 0) + UpdateSDK.downloadAndInstall(context, downloadUrl) { progress -> + _state.value = _state.value.copy(downloadProgress = progress) + } + } + } +} + +@Composable +fun UpdateScreen(viewModel: UpdateViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("版本更新", style = MaterialTheme.typography.headlineSmall) + + Card { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("App 原生更新", style = MaterialTheme.typography.titleMedium) + + val update = state.appUpdate + if (update?.needsUpdate == true) { + Text("发现新版本: ${update.versionName}") + Text("更新说明: ${update.changeLog.ifBlank { "无" }}", style = MaterialTheme.typography.bodySmall) + + if (state.downloadProgress in 0..99) { + LinearProgressIndicator( + progress = { state.downloadProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Text("下载中 ${state.downloadProgress}%") + } else { + Button( + onClick = { + update.downloadUrl.takeIf { it.isNotBlank() } + ?.let { viewModel.downloadAndInstall(context, it) } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("立即更新") } + } + } else { + state.message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + Button( + onClick = { viewModel.checkAppUpdate(context) }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isChecking, + ) { + if (state.isChecking) CircularProgressIndicator(Modifier.height(20.dp)) + else Text("检查更新") + } + } + } + } + } +} diff --git a/sample-app/src/main/res/values/themes.xml b/sample-app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2ef7101 --- /dev/null +++ b/sample-app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +