diff --git a/README.md b/README.md index aa23b91..6ef6f2f 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ SampleEnvironmentConfig.useLocalhost("10.0.2.2") 切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。 +sample-app 里也提供了一个“环境设置”页面,可以直接在外网和本地联调之间切换。 + --- ## sdk-core 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 a77dc5f..b5868ef 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 @@ -3,14 +3,12 @@ package com.xuqm.sdk.sample import android.app.Application import com.xuqm.sdk.XuqmSDK import com.xuqm.sdk.core.LogLevel -import com.xuqm.sdk.sample.config.SampleEnvironmentConfig import com.xuqm.sdk.sample.di.AppDependencies class XuqmSampleApp : Application() { override fun onCreate() { super.onCreate() - SampleEnvironmentConfig.useExternal() AppDependencies.init(this) XuqmSDK.initialize( context = this, diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt new file mode 100644 index 0000000..7fbc52d --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/repo/EnvironmentRepository.kt @@ -0,0 +1,59 @@ +package com.xuqm.sdk.sample.data.repo + +import android.content.Context +import com.xuqm.sdk.sample.config.SampleEnvironmentConfig + +enum class EnvironmentMode { + EXTERNAL, + LOCALHOST, +} + +data class EnvironmentState( + val mode: EnvironmentMode = EnvironmentMode.EXTERNAL, + val host: String = "10.0.2.2", +) + +class EnvironmentRepository(context: Context) { + + private val prefs = context.getSharedPreferences("xuqm_demo_env", Context.MODE_PRIVATE) + + fun current(): EnvironmentState = load().also { apply(it) } + + fun setExternal() { + save(EnvironmentState(mode = EnvironmentMode.EXTERNAL, host = load().host)) + SampleEnvironmentConfig.useExternal() + } + + fun setLocalhost(host: String) { + val normalizedHost = host.trim().ifBlank { "10.0.2.2" } + save(EnvironmentState(mode = EnvironmentMode.LOCALHOST, host = normalizedHost)) + SampleEnvironmentConfig.useLocalhost(normalizedHost) + } + + private fun load(): EnvironmentState { + val mode = runCatching { + EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!) + }.getOrDefault(EnvironmentMode.EXTERNAL) + val host = prefs.getString(KEY_HOST, "10.0.2.2").orEmpty().ifBlank { "10.0.2.2" } + return EnvironmentState(mode = mode, host = host) + } + + private fun save(state: EnvironmentState) { + prefs.edit() + .putString(KEY_MODE, state.mode.name) + .putString(KEY_HOST, state.host) + .apply() + } + + private fun apply(state: EnvironmentState) { + when (state.mode) { + EnvironmentMode.EXTERNAL -> SampleEnvironmentConfig.useExternal() + EnvironmentMode.LOCALHOST -> SampleEnvironmentConfig.useLocalhost(state.host) + } + } + + private companion object { + const val KEY_MODE = "mode" + const val KEY_HOST = "host" + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt index 721f218..baaf6a0 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/di/AppDependencies.kt @@ -1,15 +1,19 @@ package com.xuqm.sdk.sample.di import android.content.Context -import com.xuqm.sdk.sample.data.api.DemoApi import com.xuqm.sdk.sample.data.repo.AuthRepository +import com.xuqm.sdk.sample.data.repo.EnvironmentRepository object AppDependencies { lateinit var authRepository: AuthRepository private set + lateinit var environmentRepository: EnvironmentRepository + private set fun init(context: Context) { + environmentRepository = EnvironmentRepository(context) + environmentRepository.current() authRepository = AuthRepository(context) } } 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 index f0e8fdc..458265b 100644 --- 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 @@ -10,6 +10,7 @@ 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.environment.EnvironmentScreen import com.xuqm.sdk.sample.ui.group.GroupSettingsScreen import com.xuqm.sdk.sample.ui.main.MainScreen import java.net.URLDecoder @@ -33,6 +34,7 @@ fun AppNavGraph( } }, onNavigateToRegister = { navController.navigate("register") }, + onOpenEnvironment = { navController.navigate("environment") }, ) } composable("register") { @@ -56,6 +58,7 @@ fun AppNavGraph( onGroupSettings = { groupId -> navController.navigate("group_settings/$groupId") }, + onOpenEnvironment = { navController.navigate("environment") }, onLogout = { navController.navigate("auth") { popUpTo("main") { inclusive = true } @@ -88,5 +91,11 @@ fun AppNavGraph( onNavigateBack = { navController.popBackStack() }, ) } + + composable("environment") { + EnvironmentScreen( + 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 index 1632a40..d7e31c5 100644 --- 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 @@ -36,6 +36,7 @@ import com.xuqm.sdk.sample.di.AppDependencies fun LoginScreen( onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit, + onOpenEnvironment: () -> Unit, viewModel: LoginViewModel = viewModel( factory = viewModelFactory { initializer { LoginViewModel(AppDependencies.authRepository) } @@ -111,5 +112,9 @@ fun LoginScreen( TextButton(onClick = onNavigateToRegister) { Text("没有账号?注册") } + + TextButton(onClick = onOpenEnvironment) { + Text("环境设置") + } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt new file mode 100644 index 0000000..281f043 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/environment/EnvironmentScreen.kt @@ -0,0 +1,225 @@ +package com.xuqm.sdk.sample.ui.environment + +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.material.icons.filled.Settings +import androidx.compose.material3.Button +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.RadioButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.xuqm.sdk.sample.data.repo.EnvironmentMode +import com.xuqm.sdk.sample.data.repo.EnvironmentRepository +import com.xuqm.sdk.sample.data.repo.EnvironmentState +import com.xuqm.sdk.sample.di.AppDependencies +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +data class EnvironmentUiState( + val mode: EnvironmentMode = EnvironmentMode.EXTERNAL, + val host: String = "10.0.2.2", + val message: String? = null, +) + +class EnvironmentViewModel( + private val environmentRepository: EnvironmentRepository, +) : ViewModel() { + + private val initial = environmentRepository.current() + private val _state = MutableStateFlow(initial.toUiState()) + val state: StateFlow = _state + + fun selectExternal() { + _state.value = _state.value.copy(mode = EnvironmentMode.EXTERNAL, message = null) + } + + fun selectLocalhost() { + _state.value = _state.value.copy(mode = EnvironmentMode.LOCALHOST, message = null) + } + + fun updateHost(host: String) { + _state.value = _state.value.copy(host = host, message = null) + } + + fun apply() { + val current = _state.value + when (current.mode) { + EnvironmentMode.EXTERNAL -> environmentRepository.setExternal() + EnvironmentMode.LOCALHOST -> environmentRepository.setLocalhost(current.host) + } + _state.value = current.copy(message = "已切换并保存") + } + + private fun EnvironmentState.toUiState(): EnvironmentUiState = + EnvironmentUiState(mode = mode, host = host) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnvironmentScreen( + onNavigateBack: () -> Unit, + viewModel: EnvironmentViewModel = viewModel( + factory = viewModelFactory { + initializer { EnvironmentViewModel(AppDependencies.environmentRepository) } + } + ), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var hostInput by remember(state.host) { mutableStateOf(state.host) } + + LaunchedEffect(state.host) { + hostInput = state.host + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("环境设置") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = "切换 demo 与 SDK 的联调端点", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + + Text( + text = if (state.mode == EnvironmentMode.EXTERNAL) { + "当前使用外网服务:dev.xuqinmin.com" + } else { + "当前使用本地服务:http://${state.host}:8081" + }, + style = MaterialTheme.typography.bodyMedium, + ) + + EnvironmentModeRow( + title = "外网服务", + selected = state.mode == EnvironmentMode.EXTERNAL, + description = "走线上 / 开发服务器,适合正常联调。", + onClick = viewModel::selectExternal, + ) + + EnvironmentModeRow( + title = "本地联调", + selected = state.mode == EnvironmentMode.LOCALHOST, + description = "走本机或局域网服务,适合本地开发。", + onClick = viewModel::selectLocalhost, + ) + + OutlinedTextField( + value = hostInput, + onValueChange = { + hostInput = it + viewModel.updateHost(it) + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("本地 Host") }, + placeholder = { Text("10.0.2.2 或你的电脑局域网 IP") }, + singleLine = true, + enabled = state.mode == EnvironmentMode.LOCALHOST, + ) + + Text( + text = "切换后会立即重建 HTTP 客户端;如果 IM 已登录,会自动重连。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + + Button( + onClick = viewModel::apply, + modifier = Modifier.fillMaxWidth(), + ) { + Text("保存并应用") + } + + state.message?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EnvironmentModeRow( + title: String, + selected: Boolean, + description: String, + onClick: () -> Unit, +) { + androidx.compose.material3.Surface( + onClick = onClick, + tonalElevation = if (selected) 2.dp else 0.dp, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + androidx.compose.foundation.layout.Row( + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onClick) + Text(title, style = MaterialTheme.typography.titleSmall) + } + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } +} 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 index f31348d..1cc6d8b 100644 --- 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 @@ -8,11 +8,13 @@ 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.Settings 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.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -44,13 +46,21 @@ private val tabs = listOf( fun MainScreen( onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit, onGroupSettings: (groupId: String) -> Unit, + onOpenEnvironment: () -> Unit, onLogout: () -> Unit, ) { var selectedTab by remember { mutableIntStateOf(0) } Scaffold( topBar = { - TopAppBar(title = { Text(tabs[selectedTab].label) }) + TopAppBar( + title = { Text(tabs[selectedTab].label) }, + actions = { + IconButton(onClick = onOpenEnvironment) { + Icon(Icons.Default.Settings, contentDescription = "环境设置") + } + }, + ) }, bottomBar = { NavigationBar {