From 865dafc89882b6d2c7e66b03b67f99581a96f4bd Mon Sep 17 00:00:00 2001 From: emilia-t Date: Sat, 7 Mar 2026 19:12:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/deviceManager.xml | 13 + android-client/app/build.gradle.kts | 2 +- .../preferences/UserPreferencesRepository.kt | 23 +- .../com/onlinemsg/client/ui/ChatScreen.kt | 156 +++++++++++- .../onlinemsg/client/ui/ChatSessionManager.kt | 229 +++++++++++++++++- .../com/onlinemsg/client/ui/ChatUiState.kt | 84 +++++-- .../com/onlinemsg/client/ui/ChatViewModel.kt | 10 +- .../com/onlinemsg/client/ui/theme/Color.kt | 3 +- .../com/onlinemsg/client/ui/theme/Theme.kt | 58 ++++- .../com/onlinemsg/client/ui/theme/Type.kt | 6 +- 10 files changed, 537 insertions(+), 47 deletions(-) create mode 100644 .idea/deviceManager.xml diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index a769b8f..320a3f3 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -13,7 +13,7 @@ android { minSdk = 26 targetSdk = 34 versionCode = 1 - versionName = "1.0.0" + versionName = "1.0.0.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt index 2ad6213..e039044 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt @@ -21,7 +21,9 @@ data class UserPreferences( val currentServerUrl: String, val showSystemMessages: Boolean, val directMode: Boolean, - val shouldAutoReconnect: Boolean + val shouldAutoReconnect: Boolean, + val themeId: String = "default", + val useDynamicColor: Boolean = true ) class UserPreferencesRepository( @@ -44,10 +46,25 @@ class UserPreferencesRepository( currentServerUrl = currentServer, showSystemMessages = prefs[KEY_SHOW_SYSTEM_MESSAGES] ?: false, directMode = prefs[KEY_DIRECT_MODE] ?: false, - shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false + shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false, + themeId = prefs[KEY_THEME_ID] ?: "default", + useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true ) } + suspend fun setThemeId(themeId: String) { + context.dataStore.edit { prefs -> + prefs[KEY_THEME_ID] = themeId + } + } + + + suspend fun setUseDynamicColor(enabled: Boolean) { + context.dataStore.edit { prefs -> + prefs[KEY_USE_DYNAMIC_COLOR] = enabled + } + } + suspend fun setDisplayName(name: String) { context.dataStore.edit { prefs -> prefs[KEY_DISPLAY_NAME] = name.trim().take(64) @@ -128,5 +145,7 @@ class UserPreferencesRepository( val KEY_SHOW_SYSTEM_MESSAGES: Preferences.Key = booleanPreferencesKey("show_system_messages") val KEY_DIRECT_MODE: Preferences.Key = booleanPreferencesKey("direct_mode") val KEY_SHOULD_AUTO_RECONNECT: Preferences.Key = booleanPreferencesKey("should_auto_reconnect") + val KEY_THEME_ID: Preferences.Key = stringPreferencesKey("theme_id") + val KEY_USE_DYNAMIC_COLOR: Preferences.Key = booleanPreferencesKey("use_dynamic_color") } } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt index 714d1e2..e641d90 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -65,26 +65,45 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.foundation.isSystemInDarkTheme +import android.os.Build import com.onlinemsg.client.ui.theme.OnlineMsgTheme import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import com.onlinemsg.client.ui.theme.themeOptions +import com.onlinemsg.client.ui.theme.ThemeOption + +/** + * 主界面底部导航栏的选项卡枚举。 + * @property label 选项卡显示的文本 + */ private enum class MainTab(val label: String) { CHAT("聊天"), SETTINGS("设置") } +/** + * 应用程序的根可组合函数。 + * 集成 ViewModel、主题、Scaffold 以及选项卡切换逻辑。 + * @param viewModel 由 [viewModel] 自动提供的 [ChatViewModel] 实例 + */ @Composable -fun OnlineMsgApp( - viewModel: ChatViewModel = viewModel() -) { - OnlineMsgTheme { +fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + OnlineMsgTheme( + darkTheme = isSystemInDarkTheme(), // 仍可跟随系统 + themeId = state.themeId, + useDynamicColor = state.useDynamicColor + ) + { val state by viewModel.uiState.collectAsStateWithLifecycle() val clipboard = LocalClipboardManager.current val snackbarHostState = remember { SnackbarHostState() } var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) } + // 监听 ViewModel 发送的 UI 事件(如 Snackbar 消息) LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { @@ -164,7 +183,9 @@ fun OnlineMsgApp( }, onConnect = viewModel::connect, onDisconnect = viewModel::disconnect, - onClearMessages = viewModel::clearMessages + onClearMessages = viewModel::clearMessages, + onThemeChange = viewModel::updateTheme, + onUseDynamicColorChange = viewModel::updateUseDynamicColor ) } } @@ -172,6 +193,11 @@ fun OnlineMsgApp( } } +/** + * 应用程序顶部栏,显示标题和当前连接状态徽章。 + * @param statusText 状态文本(如“已连接”) + * @param statusColor 状态指示点的颜色 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AppTopBar( @@ -203,6 +229,20 @@ private fun AppTopBar( ) } +/** + * 聊天选项卡的主界面。 + * 包含连接控制、模式切换、消息列表和输入区域。 + * @param modifier 修饰符 + * @param state 当前的 UI 状态 + * @param onToggleDirectMode 切换广播/私聊模式 + * @param onTargetKeyChange 私聊目标公钥改变 + * @param onDraftChange 草稿消息改变 + * @param onSend 发送消息 + * @param onConnect 连接服务器 + * @param onDisconnect 断开连接 + * @param onClearMessages 清空所有消息 + * @param onCopyMessage 复制消息内容 + */ @Composable private fun ChatTab( modifier: Modifier, @@ -218,6 +258,7 @@ private fun ChatTab( ) { val listState = rememberLazyListState() + // 当消息列表新增消息时,自动滚动到底部 LaunchedEffect(state.visibleMessages.size) { if (state.visibleMessages.isNotEmpty()) { listState.animateScrollToItem(state.visibleMessages.lastIndex) @@ -230,6 +271,7 @@ private fun ChatTab( .imePadding() .padding(horizontal = 16.dp, vertical = 8.dp) ) { + // 连接状态卡片 ConnectionRow( statusHint = state.statusHint, canConnect = state.canConnect, @@ -241,6 +283,7 @@ private fun ChatTab( Spacer(modifier = Modifier.height(8.dp)) + // 广播/私聊模式切换按钮 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { FilterChip( selected = !state.directMode, @@ -254,6 +297,7 @@ private fun ChatTab( ) } + // 私聊模式下显示目标公钥输入框 if (state.directMode) { Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( @@ -268,6 +312,7 @@ private fun ChatTab( Spacer(modifier = Modifier.height(8.dp)) + // 消息列表 LazyColumn( modifier = Modifier .weight(1f) @@ -277,6 +322,7 @@ private fun ChatTab( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (state.visibleMessages.isEmpty()) { + // 无消息时显示提示卡片 item { Card( colors = CardDefaults.cardColors( @@ -302,6 +348,7 @@ private fun ChatTab( Spacer(modifier = Modifier.height(8.dp)) + // 消息输入区域 Row( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -331,6 +378,15 @@ private fun ChatTab( } } +/** + * 连接状态控制卡片,显示当前状态提示并提供连接/断开/清空按钮。 + * @param statusHint 详细的状态提示文本 + * @param canConnect 是否可连接 + * @param canDisconnect 是否可断开 + * @param onConnect 连接回调 + * @param onDisconnect 断开回调 + * @param onClearMessages 清空消息回调 + */ @Composable private fun ConnectionRow( statusHint: String, @@ -369,6 +425,12 @@ private fun ConnectionRow( } } +/** + * 单个消息气泡组件。 + * 根据消息角色(系统、发出、接收)显示不同的样式。 + * @param message 要显示的消息数据 + * @param onCopy 复制消息内容的回调 + */ @Composable private fun MessageItem( message: UiMessage, @@ -381,6 +443,7 @@ private fun MessageItem( maxWidth * 0.82f } + // 系统消息居中显示 if (message.role == MessageRole.SYSTEM) { Card( modifier = Modifier @@ -451,6 +514,7 @@ private fun MessageItem( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { + // 接收消息时显示发送者信息 if (!isOutgoing) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -471,12 +535,14 @@ private fun MessageItem( } } + // 消息正文 Text( text = message.content, style = MaterialTheme.typography.bodyMedium, color = bubbleTextColor ) + // 时间戳和复制按钮 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -505,6 +571,22 @@ private fun MessageItem( } } +/** + * 设置选项卡界面,包含个人设置、服务器管理、身份安全和诊断信息。 + * @param modifier 修饰符 + * @param state 当前的 UI 状态 + * @param onDisplayNameChange 显示名称变更 + * @param onServerUrlChange 服务器地址变更 + * @param onSaveServer 保存当前服务器地址 + * @param onRemoveServer 删除当前服务器地址 + * @param onSelectServer 选择历史服务器地址 + * @param onToggleShowSystem 切换显示系统消息 + * @param onRevealPublicKey 显示/生成公钥 + * @param onCopyPublicKey 复制公钥 + * @param onConnect 连接服务器 + * @param onDisconnect 断开连接 + * @param onClearMessages 清空消息 + */ @Composable private fun SettingsTab( modifier: Modifier, @@ -519,14 +601,17 @@ private fun SettingsTab( onCopyPublicKey: () -> Unit, onConnect: () -> Unit, onDisconnect: () -> Unit, - onClearMessages: () -> Unit + onClearMessages: () -> Unit, + onThemeChange: (String) -> Unit, + onUseDynamicColorChange: (Boolean) -> Unit ) { LazyColumn( modifier = modifier .fillMaxSize() .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + // 个人设置卡片 item { Card { Column( @@ -557,6 +642,7 @@ private fun SettingsTab( } } + // 服务器管理卡片 item { Card { Column( @@ -602,6 +688,7 @@ private fun SettingsTab( } } + // 身份与安全卡片 item { Card { Column( @@ -636,6 +723,54 @@ private fun SettingsTab( } } + // 主题设置卡片 + item { + Card { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("主题", style = MaterialTheme.typography.titleMedium) + + // 动态颜色开关(仅 Android 12+ 显示) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Switch( + checked = state.useDynamicColor, + onCheckedChange = onUseDynamicColorChange + ) + Text("使用动态颜色(跟随系统)") + } + } + + // 当动态颜色关闭时,显示预设主题选择 + if (!state.useDynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + Text("预设主题", style = MaterialTheme.typography.labelLarge) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(themeOptions) { option -> + FilterChip( + selected = state.themeId == option.id, + onClick = { onThemeChange(option.id) }, + label = { Text(option.name) }, + leadingIcon = { + Box( + modifier = Modifier + .size(16.dp) + .background(option.primary, RoundedCornerShape(4.dp)) + ) + } + ) + } + } + } + } + } + } + + // 诊断信息卡片 item { Card { Column( @@ -666,10 +801,15 @@ private fun SettingsTab( } } +/** + * 将时间戳格式化为本地时间的小时:分钟(如 "14:30")。 + * @param tsMillis 毫秒时间戳 + * @return 格式化后的时间字符串 + */ private fun formatTime(tsMillis: Long): String { val formatter = DateTimeFormatter.ofPattern("HH:mm") return Instant.ofEpochMilli(tsMillis) .atZone(ZoneId.systemDefault()) .toLocalTime() .format(formatter) -} +} \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt index 0ae56f0..f8ce61d 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -47,6 +47,10 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement +/** + * 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。 + * 所有公开方法均通过 ViewModel 代理调用,内部使用协程处理异步操作。 + */ object ChatSessionManager { private val json = Json { @@ -60,29 +64,34 @@ object ChatSessionManager { private val socketClient = OnlineMsgSocketClient() private var initialized = false + // 状态流,供 UI 层订阅 private val _uiState = MutableStateFlow(ChatUiState()) val uiState = _uiState.asStateFlow() + // 事件流(如 Snackbar 消息) private val _events = MutableSharedFlow() val events = _events.asSharedFlow() + // 用于线程安全地访问本地身份 private val identityMutex = Mutex() private var identity: RsaCryptoManager.Identity? = null - private var manualClose = false - private var fallbackTried = false - private var connectedUrl = "" - private var serverPublicKey = "" - private var helloTimeoutJob: Job? = null - private var authTimeoutJob: Job? = null - private var reconnectJob: Job? = null - private var reconnectAttempt: Int = 0 - private val systemMessageExpiryJobs: MutableMap = mutableMapOf() + // 连接相关内部状态 + private var manualClose = false // 是否为手动断开 + private var fallbackTried = false // 是否已尝试切换 ws/wss + private var connectedUrl = "" // 当前连接的服务器地址 + private var serverPublicKey = "" // 服务端公钥(握手后获得) + private var helloTimeoutJob: Job? = null // 握手超时任务 + private var authTimeoutJob: Job? = null // 认证超时任务 + private var reconnectJob: Job? = null // 自动重连任务 + private var reconnectAttempt: Int = 0 // 当前重连尝试次数 + private val systemMessageExpiryJobs: MutableMap = mutableMapOf() // 系统消息自动过期任务 private var autoReconnectTriggered = false @Volatile - private var keepAliveRequested = false + private var keepAliveRequested = false // 是否应保活(前台服务标志) private var notificationIdSeed = 2000 + // WebSocket 事件监听器 private val socketListener = object : OnlineMsgSocketClient.Listener { override fun onOpen() { scope.launch { @@ -151,6 +160,10 @@ object ChatSessionManager { } } + /** + * 初始化管理器,必须在应用启动时调用一次。 + * @param application Application 实例 + */ @Synchronized fun initialize(application: Application) { if (initialized) return @@ -169,9 +182,12 @@ object ChatSessionManager { serverUrls = pref.serverUrls, serverUrl = pref.currentServerUrl, directMode = pref.directMode, - showSystemMessages = pref.showSystemMessages + showSystemMessages = pref.showSystemMessages, + themeId = pref.themeId, // 假设 preferences 中有 themeId + useDynamicColor = pref.useDynamicColor ) } + // 如果上次会话启用了自动重连,则自动恢复连接 if (pref.shouldAutoReconnect && !autoReconnectTriggered) { autoReconnectTriggered = true ChatForegroundService.start(application) @@ -180,6 +196,32 @@ object ChatSessionManager { } } + /** + * 更新主题 + * @param themeId 主题名 + */ + fun updateTheme(themeId: String) { + _uiState.update { it.copy(themeId = themeId) } + scope.launch { + preferencesRepository.setThemeId(themeId) + } + } + + /** + * 更改使用动态颜色 + * @param enabled 主题名 + */ + fun updateUseDynamicColor(enabled: Boolean) { + _uiState.update { it.copy(useDynamicColor = enabled) } + scope.launch { + preferencesRepository.setUseDynamicColor(enabled) + } + } + + /** + * 更新显示名称并持久化。 + * @param value 新名称(自动截断至 64 字符) + */ fun updateDisplayName(value: String) { val displayName = value.take(64) _uiState.update { it.copy(displayName = displayName) } @@ -188,18 +230,34 @@ object ChatSessionManager { } } + /** + * 更新当前输入的服务器地址(不持久化)。 + * @param value 新地址 + */ fun updateServerUrl(value: String) { _uiState.update { it.copy(serverUrl = value) } } + /** + * 更新私聊目标公钥。 + * @param value 公钥字符串 + */ fun updateTargetKey(value: String) { _uiState.update { it.copy(targetKey = value) } } + /** + * 更新消息草稿。 + * @param value 草稿内容 + */ fun updateDraft(value: String) { _uiState.update { it.copy(draft = value) } } + /** + * 切换广播/私聊模式并持久化。 + * @param enabled true 为私聊模式 + */ fun toggleDirectMode(enabled: Boolean) { _uiState.update { it.copy(directMode = enabled) } scope.launch { @@ -207,6 +265,10 @@ object ChatSessionManager { } } + /** + * 切换是否显示系统消息并持久化。 + * @param show true 显示 + */ fun toggleShowSystemMessages(show: Boolean) { _uiState.update { it.copy(showSystemMessages = show) } scope.launch { @@ -214,11 +276,17 @@ object ChatSessionManager { } } + /** + * 清空所有消息,并取消系统消息的过期任务。 + */ fun clearMessages() { cancelSystemMessageExpiryJobs() _uiState.update { it.copy(messages = emptyList()) } } + /** + * 保存当前服务器地址到历史列表并持久化。 + */ fun saveCurrentServerUrl() { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) { @@ -243,6 +311,10 @@ object ChatSessionManager { } } + /** + * 从历史列表中移除当前服务器地址。 + * 如果列表清空则恢复默认地址。 + */ fun removeCurrentServerUrl() { val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) if (normalized.isBlank()) return @@ -268,6 +340,9 @@ object ChatSessionManager { } } + /** + * 加载或生成本地身份密钥对,并将公钥显示到 UI。 + */ fun revealMyPublicKey() { scope.launch { _uiState.update { it.copy(loadingPublicKey = true) } @@ -287,10 +362,17 @@ object ChatSessionManager { } } + /** + * 主动连接服务器(由用户点击连接触发)。 + */ fun connect() { connectInternal(isAutoRestore = false) } + /** + * 内部连接逻辑,区分自动恢复和手动连接。 + * @param isAutoRestore 是否为应用启动时的自动恢复连接 + */ private fun connectInternal(isAutoRestore: Boolean) { if (!initialized) return val state = _uiState.value @@ -339,6 +421,10 @@ object ChatSessionManager { } } + /** + * 主动断开连接。 + * @param stopService 是否同时停止前台服务(默认 true) + */ fun disconnect(stopService: Boolean = true) { manualClose = true cancelReconnect() @@ -362,6 +448,10 @@ object ChatSessionManager { addSystemMessage("已断开连接") } + /** + * 发送消息(广播或私聊)。 + * 执行签名、加密并发送。 + */ fun sendMessage() { val current = _uiState.value if (!current.canSend) return @@ -419,12 +509,19 @@ object ChatSessionManager { } } + /** + * 消息复制成功后的回调,显示“已复制”提示。 + */ fun onMessageCopied() { scope.launch { _events.emit(UiEvent.ShowSnackbar("已复制")) } } + /** + * 确保本地身份已加载或创建。 + * @return 本地身份对象 + */ private suspend fun ensureIdentity(): RsaCryptoManager.Identity { return identityMutex.withLock { identity ?: withContext(Dispatchers.Default) { @@ -435,6 +532,10 @@ object ChatSessionManager { } } + /** + * 处理收到的原始文本消息(可能是握手包或加密消息)。 + * @param rawText 原始文本 + */ private suspend fun handleIncomingMessage(rawText: String) { if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") } @@ -445,7 +546,7 @@ object ChatSessionManager { json.decodeFromString(normalizedText) as? JsonObject }.getOrNull() - // 兼容某些代理/中间层直接转发 hello data 对象(没有 envelope 外层) + // 尝试直接解析为 HelloDataDto(某些服务器可能直接发送,不带外层) val directHello = rootObject?.let { obj -> val hasPublicKey = obj["publicKey"] != null val hasChallenge = obj["authChallenge"] != null @@ -461,6 +562,7 @@ object ChatSessionManager { return } + // 尝试解析为带外层的 EnvelopeDto val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull() if (plain?.type == "publickey") { cancelHelloTimeout() @@ -480,6 +582,7 @@ object ChatSessionManager { return } + // 握手阶段收到非预期消息则报错 if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") } addSystemMessage("握手阶段收到非预期消息类型:${plain.type}") @@ -492,6 +595,7 @@ object ChatSessionManager { addSystemMessage("握手包解析失败:$preview") } + // 尝试解密(若已握手完成,收到的应是加密消息) val id = ensureIdentity() val decrypted = runCatching { withContext(Dispatchers.Default) { @@ -509,6 +613,10 @@ object ChatSessionManager { handleSecureMessage(secure) } + /** + * 处理服务端发来的握手 Hello 数据。 + * @param hello 服务端公钥和挑战 + */ private suspend fun handleServerHello(hello: HelloDataDto) { cancelHelloTimeout() serverPublicKey = hello.publicKey @@ -552,6 +660,10 @@ object ChatSessionManager { } } + /** + * 发送认证消息(包含签名后的身份信息)。 + * @param challenge 服务端提供的挑战值 + */ private suspend fun sendAuth(challenge: String) { val id = ensureIdentity() val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() } @@ -595,6 +707,10 @@ object ChatSessionManager { check(socketClient.send(cipher)) { "连接不可用" } } + /** + * 处理安全通道建立后的业务消息(广播、私聊、认证结果等)。 + * @param message 解密后的 EnvelopeDto + */ private fun handleSecureMessage(message: EnvelopeDto) { when (message.type) { "auth_ok" -> { @@ -634,6 +750,11 @@ object ChatSessionManager { } } + /** + * 处理 WebSocket 连接关闭事件。 + * @param code 关闭状态码 + * @param reason 关闭原因 + */ private fun handleSocketClosed(code: Int, reason: String) { cancelHelloTimeout() cancelAuthTimeout() @@ -652,6 +773,7 @@ object ChatSessionManager { val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY + // 尝试切换 ws/wss 协议重试(仅限非就绪状态) if (allowFallback) { val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl) if (fallbackUrl.isNotBlank()) { @@ -680,6 +802,10 @@ object ChatSessionManager { scheduleReconnect("连接已中断") } + /** + * 添加一条系统消息(自动按 TTL 过期)。 + * @param content 消息内容 + */ private fun addSystemMessage(content: String) { val message = UiMessage( role = MessageRole.SYSTEM, @@ -692,6 +818,13 @@ object ChatSessionManager { scheduleSystemMessageExpiry(message.id) } + /** + * 添加一条接收到的用户消息。 + * @param sender 发送者名称 + * @param subtitle 附加说明(如私聊来源) + * @param content 消息内容 + * @param channel 消息通道(广播/私聊) + */ private fun addIncomingMessage( sender: String, subtitle: String, @@ -713,6 +846,12 @@ object ChatSessionManager { ) } + /** + * 添加一条发出的消息。 + * @param content 消息内容 + * @param subtitle 附加说明(如私聊目标) + * @param channel 消息通道 + */ private fun addOutgoingMessage( content: String, subtitle: String, @@ -729,6 +868,10 @@ object ChatSessionManager { ) } + /** + * 将消息追加到列表尾部,并清理超出数量限制的消息。 + * @param message 要追加的消息 + */ private fun appendMessage(message: UiMessage) { _uiState.update { current -> val next = (current.messages + message).takeLast(MAX_MESSAGES) @@ -741,11 +884,18 @@ object ChatSessionManager { } } + /** + * 取消认证超时任务。 + */ private fun cancelAuthTimeout() { authTimeoutJob?.cancel() authTimeoutJob = null } + /** + * 安排自动重连(指数退避)。 + * @param reason 触发重连的原因 + */ private fun scheduleReconnect(reason: String) { if (manualClose) return if (reconnectJob?.isActive == true) return @@ -793,11 +943,18 @@ object ChatSessionManager { } } + /** + * 取消自动重连任务。 + */ private fun cancelReconnect() { reconnectJob?.cancel() reconnectJob = null } + /** + * 为系统消息安排过期自动删除。 + * @param messageId 消息唯一 ID + */ private fun scheduleSystemMessageExpiry(messageId: String) { systemMessageExpiryJobs.remove(messageId)?.cancel() systemMessageExpiryJobs[messageId] = scope.launch { @@ -810,11 +967,17 @@ object ChatSessionManager { } } + /** + * 取消所有系统消息的过期任务。 + */ private fun cancelSystemMessageExpiryJobs() { systemMessageExpiryJobs.values.forEach { it.cancel() } systemMessageExpiryJobs.clear() } + /** + * 启动握手超时计时器。 + */ private fun startHelloTimeout() { cancelHelloTimeout() helloTimeoutJob = scope.launch { @@ -833,21 +996,38 @@ object ChatSessionManager { } } + /** + * 取消握手超时任务。 + */ private fun cancelHelloTimeout() { helloTimeoutJob?.cancel() helloTimeoutJob = null } + /** + * 缩写显示公钥(取前后各8字符)。 + * @param key 完整公钥 + * @return 缩写字符串 + */ private fun summarizeKey(key: String): String { if (key.length <= 16) return key return "${key.take(8)}...${key.takeLast(8)}" } + /** + * 生成访客名称(如 guest-123456)。 + * @return 随机名称 + */ private fun createGuestName(): String { val rand = (100000..999999).random() return "guest-$rand" } + /** + * 从可能包含前缀的原始文本中提取 JSON 对象部分。 + * @param rawText 原始文本 + * @return 最外层的 JSON 字符串 + */ private fun extractJsonCandidate(rawText: String): String { val trimmed = rawText.trim() if (trimmed.startsWith("{") && trimmed.endsWith("}")) { @@ -863,6 +1043,9 @@ object ChatSessionManager { } } + /** + * 关闭所有资源(用于应用退出时)。 + */ fun shutdownAll() { cancelSystemMessageExpiryJobs() cancelReconnect() @@ -871,6 +1054,9 @@ object ChatSessionManager { socketClient.shutdown() } + /** + * 前台服务停止时的回调。 + */ fun onForegroundServiceStopped() { keepAliveRequested = false if (_uiState.value.status != ConnectionStatus.IDLE) { @@ -882,8 +1068,15 @@ object ChatSessionManager { } } + /** + * 判断前台服务是否应该运行。 + * @return true 表示应保持服务运行 + */ fun shouldForegroundServiceRun(): Boolean = keepAliveRequested + /** + * 创建消息通知渠道(Android O+)。 + */ private fun ensureMessageNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -897,6 +1090,11 @@ object ChatSessionManager { manager.createNotificationChannel(channel) } + /** + * 显示新消息到达的通知。 + * @param title 通知标题 + * @param body 通知正文 + */ private fun showIncomingNotification(title: String, body: String) { if (!initialized) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -928,16 +1126,21 @@ object ChatSessionManager { NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification) } + /** + * 生成下一个通知 ID(线程安全递增)。 + * @return 新的通知 ID + */ @Synchronized private fun nextMessageNotificationId(): Int { notificationIdSeed += 1 return notificationIdSeed } + // 常量定义 private const val HELLO_TIMEOUT_MS = 12_000L private const val AUTH_TIMEOUT_MS = 20_000L private const val MAX_MESSAGES = 500 private const val MAX_RECONNECT_DELAY_SECONDS = 30 private const val SYSTEM_MESSAGE_TTL_MS = 1_000L private const val MESSAGE_CHANNEL_ID = "onlinemsg_messages" -} +} \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt index 52b4785..88ecb88 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt @@ -2,26 +2,45 @@ package com.onlinemsg.client.ui import java.util.UUID +/** + * 连接状态枚举。 + */ enum class ConnectionStatus { - IDLE, - CONNECTING, - HANDSHAKING, - AUTHENTICATING, - READY, - ERROR + IDLE, // 未连接 + CONNECTING, // 连接中 + HANDSHAKING, // 握手阶段 + AUTHENTICATING, // 认证阶段 + READY, // 已就绪 + ERROR // 错误 } +/** + * 消息角色。 + */ enum class MessageRole { - SYSTEM, - INCOMING, - OUTGOING + SYSTEM, // 系统消息 + INCOMING, // 接收到的消息 + OUTGOING // 发出的消息 } +/** + * 消息通道(广播/私聊)。 + */ enum class MessageChannel { BROADCAST, PRIVATE } +/** + * 单条消息的数据类。 + * @property id 唯一标识(默认随机 UUID) + * @property role 消息角色 + * @property sender 发送者显示名称 + * @property subtitle 附加说明(如私聊来源/目标缩写) + * @property content 消息内容 + * @property channel 消息通道 + * @property timestampMillis 消息时间戳(毫秒) + */ data class UiMessage( val id: String = UUID.randomUUID().toString(), val role: MessageRole, @@ -32,6 +51,23 @@ data class UiMessage( val timestampMillis: Long = System.currentTimeMillis() ) +/** + * 整个聊天界面的状态数据类。 + * @property status 连接状态 + * @property statusHint 详细状态提示文本 + * @property displayName 用户显示名称 + * @property serverUrls 已保存的服务器地址列表 + * @property serverUrl 当前输入的服务器地址 + * @property directMode 是否为私聊模式 + * @property targetKey 私聊目标公钥 + * @property draft 输入框草稿 + * @property messages 所有消息列表 + * @property showSystemMessages 是否显示系统消息 + * @property certFingerprint 服务器证书指纹 + * @property myPublicKey 本地公钥 + * @property sending 是否正在发送消息(用于禁用按钮) + * @property loadingPublicKey 是否正在加载公钥 + */ data class ChatUiState( val status: ConnectionStatus = ConnectionStatus.IDLE, val statusHint: String = "点击连接开始聊天", @@ -46,20 +82,34 @@ data class ChatUiState( val certFingerprint: String = "", val myPublicKey: String = "", val sending: Boolean = false, - val loadingPublicKey: Boolean = false + val loadingPublicKey: Boolean = false, + val themeId: String = "default", /* 当前选中的主题名 (@emilia-t)*/ + val useDynamicColor: Boolean = true /* 是否使用 Android 12+ 动态颜色 (@emilia-t)*/ ) { + /** + * 是否允许连接。 + */ val canConnect: Boolean get() = status == ConnectionStatus.IDLE || status == ConnectionStatus.ERROR + /** + * 是否允许断开连接。 + */ val canDisconnect: Boolean get() = status == ConnectionStatus.CONNECTING || - status == ConnectionStatus.HANDSHAKING || - status == ConnectionStatus.AUTHENTICATING || - status == ConnectionStatus.READY + status == ConnectionStatus.HANDSHAKING || + status == ConnectionStatus.AUTHENTICATING || + status == ConnectionStatus.READY + /** + * 是否允许发送消息(就绪且草稿非空且不在发送中)。 + */ val canSend: Boolean get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending + /** + * 连接状态的简短文本描述。 + */ val statusText: String get() = when (status) { ConnectionStatus.IDLE -> "未连接" @@ -70,6 +120,9 @@ data class ChatUiState( ConnectionStatus.ERROR -> "异常断开" } + /** + * 根据当前模式(广播/私聊)和是否显示系统消息,过滤出要显示的消息列表。 + */ val visibleMessages: List get() = messages.filter { item -> if (item.role == MessageRole.SYSTEM) { @@ -81,6 +134,9 @@ data class ChatUiState( } } +/** + * UI 事件接口,用于向界面发送一次性通知。 + */ sealed interface UiEvent { data class ShowSnackbar(val message: String) : UiEvent -} +} \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt index ee5b93c..d6b55ba 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt @@ -3,6 +3,11 @@ package com.onlinemsg.client.ui import android.app.Application import androidx.lifecycle.AndroidViewModel +/** + * ViewModel 层,作为 UI 与 [ChatSessionManager] 的桥梁。 + * 初始化会话管理器并暴露其状态和事件流,同时提供所有用户操作的代理方法。 + * @param application Application 实例 + */ class ChatViewModel(application: Application) : AndroidViewModel(application) { init { @@ -26,4 +31,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun disconnect() = ChatSessionManager.disconnect() fun sendMessage() = ChatSessionManager.sendMessage() fun onMessageCopied() = ChatSessionManager.onMessageCopied() -} + + fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) + fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) +} \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt index 0a1e4b6..f73b859 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt @@ -2,9 +2,10 @@ package com.onlinemsg.client.ui.theme import androidx.compose.ui.graphics.Color +// 应用的主色调、表面色、错误色等定义 val Primary = Color(0xFF0C6D62) val OnPrimary = Color(0xFFFFFFFF) val Secondary = Color(0xFF4A635F) val Surface = Color(0xFFF7FAF8) val SurfaceVariant = Color(0xFFDCE8E4) -val Error = Color(0xFFB3261E) +val Error = Color(0xFFB3261E) \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt index df0d31d..2843bee 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color private val LightColors = lightColorScheme( primary = Primary, @@ -25,20 +26,47 @@ private val DarkColors = darkColorScheme( error = Error ) +/** + * 应用主题可组合函数。 + * 支持浅色/深色模式以及 Android 12+ 的动态颜色。 + * @param darkTheme 是否强制深色模式(默认跟随系统) + * @param dynamicColor 是否启用动态颜色(默认 true) + * @param content 内部内容 + */ @Composable fun OnlineMsgTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, + themeId: String = "default", // 从外部传入 + useDynamicColor: Boolean = true, // 从外部传入 content: @Composable () -> Unit ) { + val context = LocalContext.current val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current + // 优先使用动态颜色(如果启用且系统支持) + useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - - darkTheme -> DarkColors - else -> LightColors + // 否则根据主题 ID 选择预设 + else -> { + val option = themeOptions.find { it.id == themeId } ?: themeOptions.first() + if (darkTheme) { + darkColorScheme( + primary = option.primary, + secondary = option.secondary, + error = option.error ?: Error, + surface = option.surface ?: Surface, + // 其他颜色可以沿用默认深色调整 + ) + } else { + lightColorScheme( + primary = option.primary, + secondary = option.secondary, + error = option.error ?: Error, + surface = option.surface ?: Surface, + // 其他颜色保持默认浅色 + ) + } + } } MaterialTheme( @@ -47,3 +75,21 @@ fun OnlineMsgTheme( content = content ) } + +// 主题选项数据类 +data class ThemeOption( + val id: String, + val name: String, + val primary: Color, + val secondary: Color, + val surface: Color? = null, + val error: Color? = null +) + +// 预设主题列表 +val themeOptions = listOf( + ThemeOption("default", "默认", Primary, Secondary), + ThemeOption("blue", "蔚蓝", Color(0xFF1E88E5), Color(0xFF6A8DAA)), + ThemeOption("green", "翠绿", Color(0xFF2E7D32), Color(0xFF4A635F)), + ThemeOption("red", "绯红", Color(0xFFC62828), Color(0xFF8D6E63)) +) \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt index cc0f902..6cd1077 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt @@ -2,4 +2,8 @@ package com.onlinemsg.client.ui.theme import androidx.compose.material3.Typography -val AppTypography = Typography() +/** + * 应用程序的默认排版样式。 + * 使用 Material3 默认排版,可根据需要自定义。 + */ +val AppTypography = Typography() \ No newline at end of file From e9d519554b165c10599d501b8a2983e9d028afd3 Mon Sep 17 00:00:00 2001 From: emilia-t Date: Sun, 8 Mar 2026 12:33:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?1=E4=BF=AE=E5=A4=8D=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=982?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=83=A8=E5=88=86UI3=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E6=94=AF=E6=8C=814=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/gradle.xml | 12 + .idea/studiobot.xml | 6 + android-client/app/build.gradle.kts | 2 +- .../preferences/UserPreferencesRepository.kt | 16 +- .../com/onlinemsg/client/ui/ChatScreen.kt | 617 ++++++++---------- .../onlinemsg/client/ui/ChatSessionManager.kt | 20 +- .../com/onlinemsg/client/ui/ChatUiState.kt | 6 +- .../com/onlinemsg/client/ui/ChatViewModel.kt | 3 +- .../com/onlinemsg/client/ui/theme/Color.kt | 18 +- .../com/onlinemsg/client/ui/theme/Theme.kt | 67 +- .../onlinemsg/client/util/LanguageManager.kt | 188 ++++++ 11 files changed, 573 insertions(+), 382 deletions(-) create mode 100644 .idea/gradle.xml create mode 100644 .idea/studiobot.xml create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..361eddd --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index 320a3f3..7581e5a 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -13,7 +13,7 @@ android { minSdk = 26 targetSdk = 34 versionCode = 1 - versionName = "1.0.0.1" + versionName = "1.0.0.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt index e039044..9e7974b 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt @@ -22,8 +22,9 @@ data class UserPreferences( val showSystemMessages: Boolean, val directMode: Boolean, val shouldAutoReconnect: Boolean, - val themeId: String = "default", - val useDynamicColor: Boolean = true + val themeId: String = "blue", + val useDynamicColor: Boolean = true, + val language: String = "zh" // 默认中文 ) class UserPreferencesRepository( @@ -47,8 +48,9 @@ class UserPreferencesRepository( showSystemMessages = prefs[KEY_SHOW_SYSTEM_MESSAGES] ?: false, directMode = prefs[KEY_DIRECT_MODE] ?: false, shouldAutoReconnect = prefs[KEY_SHOULD_AUTO_RECONNECT] ?: false, - themeId = prefs[KEY_THEME_ID] ?: "default", - useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true + themeId = prefs[KEY_THEME_ID] ?: "blue", + useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true, + language = prefs[KEY_LANGUAGE] ?: "zh" ) } @@ -58,6 +60,11 @@ class UserPreferencesRepository( } } + suspend fun setLanguage(language: String) { + context.dataStore.edit { prefs -> + prefs[KEY_LANGUAGE] = language + } + } suspend fun setUseDynamicColor(enabled: Boolean) { context.dataStore.edit { prefs -> @@ -147,5 +154,6 @@ class UserPreferencesRepository( val KEY_SHOULD_AUTO_RECONNECT: Preferences.Key = booleanPreferencesKey("should_auto_reconnect") val KEY_THEME_ID: Preferences.Key = stringPreferencesKey("theme_id") val KEY_USE_DYNAMIC_COLOR: Preferences.Key = booleanPreferencesKey("use_dynamic_color") + val KEY_LANGUAGE: Preferences.Key = stringPreferencesKey("language") } } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt index e641d90..f84fc1e 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -1,10 +1,11 @@ package com.onlinemsg.client.ui +import android.annotation.SuppressLint import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding @@ -28,6 +29,9 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Language import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -48,6 +52,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -71,17 +76,16 @@ import com.onlinemsg.client.ui.theme.OnlineMsgTheme import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -import com.onlinemsg.client.ui.theme.themeOptions -import com.onlinemsg.client.ui.theme.ThemeOption +import com.onlinemsg.client.ui.theme.themeOptions +import com.onlinemsg.client.util.LanguageManager /** * 主界面底部导航栏的选项卡枚举。 - * @property label 选项卡显示的文本 */ -private enum class MainTab(val label: String) { - CHAT("聊天"), - SETTINGS("设置") +private enum class MainTab(val labelKey: String) { + CHAT("tab.chat"), + SETTINGS("tab.settings") } /** @@ -91,6 +95,7 @@ private enum class MainTab(val label: String) { */ @Composable fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() OnlineMsgTheme( darkTheme = isSystemInDarkTheme(), // 仍可跟随系统 @@ -98,11 +103,13 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { useDynamicColor = state.useDynamicColor ) { - val state by viewModel.uiState.collectAsStateWithLifecycle() val clipboard = LocalClipboardManager.current val snackbarHostState = remember { SnackbarHostState() } var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) } + // 定义翻译函数 t + fun t(key: String) = LanguageManager.getString(key, state.language) + // 监听 ViewModel 发送的 UI 事件(如 Snackbar 消息) LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -124,18 +131,36 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { ) }, bottomBar = { - NavigationBar(modifier = Modifier.navigationBarsPadding()) { + NavigationBar( + modifier = Modifier + .navigationBarsPadding() + .height(64.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp + ) { NavigationBarItem( selected = tab == MainTab.CHAT, onClick = { tab = MainTab.CHAT }, - label = { Text(MainTab.CHAT.label) }, - icon = {} + label = { Text(t(MainTab.CHAT.labelKey), style = MaterialTheme.typography.labelSmall) }, + icon = { + Icon( + imageVector = Icons.Rounded.Forum, + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + } ) NavigationBarItem( selected = tab == MainTab.SETTINGS, onClick = { tab = MainTab.SETTINGS }, - label = { Text(MainTab.SETTINGS.label) }, - icon = {} + label = { Text(t(MainTab.SETTINGS.labelKey), style = MaterialTheme.typography.labelSmall) }, + icon = { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + } ) } }, @@ -185,7 +210,8 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onDisconnect = viewModel::disconnect, onClearMessages = viewModel::clearMessages, onThemeChange = viewModel::updateTheme, - onUseDynamicColorChange = viewModel::updateUseDynamicColor + onUseDynamicColorChange = viewModel::updateUseDynamicColor, + onLanguageChange = viewModel::updateLanguage ) } } @@ -195,7 +221,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { /** * 应用程序顶部栏,显示标题和当前连接状态徽章。 - * @param statusText 状态文本(如“已连接”) + * @param statusText 状态文本 * @param statusColor 状态指示点的颜色 */ @OptIn(ExperimentalMaterial3Api::class) @@ -207,8 +233,8 @@ private fun AppTopBar( TopAppBar( title = { Text( - text = "OnlineMsg Chat", - style = MaterialTheme.typography.titleLarge + text = "OnlineMsg", + style = MaterialTheme.typography.titleMedium ) }, actions = { @@ -218,30 +244,25 @@ private fun AppTopBar( leadingIcon = { Box( modifier = Modifier - .width(10.dp) - .height(10.dp) + .size(8.dp) .background(statusColor, RoundedCornerShape(999.dp)) ) - } + }, + modifier = Modifier.height(32.dp) ) Spacer(modifier = Modifier.width(12.dp)) - } + }, + windowInsets = WindowInsets(top = 20.dp), // 顶部高度(状态栏) + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface + ) ) } /** * 聊天选项卡的主界面。 - * 包含连接控制、模式切换、消息列表和输入区域。 - * @param modifier 修饰符 - * @param state 当前的 UI 状态 - * @param onToggleDirectMode 切换广播/私聊模式 - * @param onTargetKeyChange 私聊目标公钥改变 - * @param onDraftChange 草稿消息改变 - * @param onSend 发送消息 - * @param onConnect 连接服务器 - * @param onDisconnect 断开连接 - * @param onClearMessages 清空所有消息 - * @param onCopyMessage 复制消息内容 + * 包含模式切换、消息列表和输入区域。 */ @Composable private fun ChatTab( @@ -257,6 +278,9 @@ private fun ChatTab( onCopyMessage: (String) -> Unit ) { val listState = rememberLazyListState() + + // 定义翻译函数 t + fun t(key: String) = LanguageManager.getString(key, state.language) // 当消息列表新增消息时,自动滚动到底部 LaunchedEffect(state.visibleMessages.size) { @@ -269,48 +293,42 @@ private fun ChatTab( modifier = modifier .fillMaxSize() .imePadding() - .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) // 移除了顶部多余 padding ) { - // 连接状态卡片 - ConnectionRow( - statusHint = state.statusHint, - canConnect = state.canConnect, - canDisconnect = state.canDisconnect, - onConnect = onConnect, - onDisconnect = onDisconnect, - onClearMessages = onClearMessages - ) - - Spacer(modifier = Modifier.height(8.dp)) - // 广播/私聊模式切换按钮 - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { FilterChip( selected = !state.directMode, onClick = { onToggleDirectMode(false) }, - label = { Text("广播") } + label = { Text(t("chat.broadcast")) } ) FilterChip( selected = state.directMode, onClick = { onToggleDirectMode(true) }, - label = { Text("私聊") } + label = { Text(t("chat.private")) } ) + + // 在这一行腾出的空间可以放置其他快捷操作,或者保持简洁 } // 私聊模式下显示目标公钥输入框 if (state.directMode) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) OutlinedTextField( value = state.targetKey, onValueChange = onTargetKeyChange, modifier = Modifier.fillMaxWidth(), - label = { Text("目标公钥") }, - placeholder = { Text("私聊模式:粘贴目标公钥") }, + label = { Text(t("chat.target_key")) }, + placeholder = { Text(t("chat.target_key")) }, maxLines = 3 ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) // 消息列表 LazyColumn( @@ -318,7 +336,7 @@ private fun ChatTab( .weight(1f) .fillMaxWidth(), state = listState, - contentPadding = PaddingValues(vertical = 8.dp), + contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (state.visibleMessages.isEmpty()) { @@ -330,7 +348,7 @@ private fun ChatTab( ) ) { Text( - text = "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。", + text = t("chat.empty_hint"), modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium ) @@ -340,7 +358,8 @@ private fun ChatTab( items(state.visibleMessages, key = { it.id }) { message -> MessageItem( message = message, - onCopy = { onCopyMessage(message.content) } + onCopy = { onCopyMessage(message.content) }, + currentLanguage = state.language ) } } @@ -357,7 +376,7 @@ private fun ChatTab( value = state.draft, onValueChange = onDraftChange, modifier = Modifier.weight(1f), - label = { Text("输入消息") }, + label = { Text(t("chat.input_placeholder")) }, maxLines = 4, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions( @@ -370,56 +389,9 @@ private fun ChatTab( enabled = state.canSend, modifier = Modifier.height(56.dp) ) { - Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = "发送") + Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null) Spacer(Modifier.width(6.dp)) - Text(if (state.sending) "发送中" else "发送") - } - } - } -} - -/** - * 连接状态控制卡片,显示当前状态提示并提供连接/断开/清空按钮。 - * @param statusHint 详细的状态提示文本 - * @param canConnect 是否可连接 - * @param canDisconnect 是否可断开 - * @param onConnect 连接回调 - * @param onDisconnect 断开回调 - * @param onClearMessages 清空消息回调 - */ -@Composable -private fun ConnectionRow( - statusHint: String, - canConnect: Boolean, - canDisconnect: Boolean, - onConnect: () -> Unit, - onDisconnect: () -> Unit, - onClearMessages: () -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) - ) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "在线会话", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = statusHint, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onConnect, enabled = canConnect) { - Text("连接") - } - OutlinedButton(onClick = onDisconnect, enabled = canDisconnect) { - Text("断开") - } - OutlinedButton(onClick = onClearMessages) { - Text("清空") - } + Text(if (state.sending) "..." else t("chat.send")) } } } @@ -427,144 +399,146 @@ private fun ConnectionRow( /** * 单个消息气泡组件。 - * 根据消息角色(系统、发出、接收)显示不同的样式。 - * @param message 要显示的消息数据 - * @param onCopy 复制消息内容的回调 */ +@SuppressLint("UnusedBoxWithConstraintsScope") @Composable private fun MessageItem( message: UiMessage, - onCopy: () -> Unit + onCopy: () -> Unit, + currentLanguage: String ) { - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val maxBubbleWidth = if (message.role == MessageRole.SYSTEM) { - maxWidth * 0.9f - } else { - maxWidth * 0.82f - } + Box(modifier = Modifier.fillMaxWidth()) { + val isSystem = message.role == MessageRole.SYSTEM - // 系统消息居中显示 - if (message.role == MessageRole.SYSTEM) { - Card( + if (isSystem) { + // 系统消息居中显示,最大占比 90% + Box( modifier = Modifier - .widthIn(max = maxBubbleWidth) + .fillMaxWidth(0.9f) .align(Alignment.Center), - shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = message.content, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = formatTime(message.timestampMillis), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + Card( + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer ) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = message.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = formatTime(message.timestampMillis), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + } } } - return@BoxWithConstraints - } - - val isOutgoing = message.role == MessageRole.OUTGOING - val bubbleColor = if (isOutgoing) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainer - } - val bubbleTextColor = if (isOutgoing) { - MaterialTheme.colorScheme.onPrimaryContainer } else { - MaterialTheme.colorScheme.onSurface - } - val bubbleShape = if (isOutgoing) { - RoundedCornerShape( - topStart = 18.dp, - topEnd = 6.dp, - bottomEnd = 18.dp, - bottomStart = 18.dp - ) - } else { - RoundedCornerShape( - topStart = 6.dp, - topEnd = 18.dp, - bottomEnd = 18.dp, - bottomStart = 18.dp - ) - } + val isOutgoing = message.role == MessageRole.OUTGOING + val bubbleColor = if (isOutgoing) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + } + val bubbleTextColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + val bubbleShape = if (isOutgoing) { + RoundedCornerShape( + topStart = 18.dp, + topEnd = 6.dp, + bottomEnd = 18.dp, + bottomStart = 18.dp + ) + } else { + RoundedCornerShape( + topStart = 6.dp, + topEnd = 18.dp, + bottomEnd = 18.dp, + bottomStart = 18.dp + ) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start - ) { - Card( - modifier = Modifier.widthIn(max = maxBubbleWidth), - shape = bubbleShape, - colors = CardDefaults.cardColors(containerColor = bubbleColor) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + // 使用 Box(fillMaxWidth(0.82f)) 限制气泡最大宽度占比 + Box( + modifier = Modifier.fillMaxWidth(0.82f), + contentAlignment = if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart ) { - // 接收消息时显示发送者信息 - if (!isOutgoing) { - Row(verticalAlignment = Alignment.CenterVertically) { + Card( + shape = bubbleShape, + colors = CardDefaults.cardColors(containerColor = bubbleColor) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (!isOutgoing) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = message.sender, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + if (message.subtitle.isNotBlank()) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = message.subtitle, + style = MaterialTheme.typography.labelSmall, + color = bubbleTextColor.copy(alpha = 0.75f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + // 消息正文 Text( - text = message.sender, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary + text = message.content, + style = MaterialTheme.typography.bodyMedium, + color = bubbleTextColor ) - if (message.subtitle.isNotBlank()) { - Spacer(modifier = Modifier.width(8.dp)) + + // 时间戳和复制按钮 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.weight(1f)) Text( - text = message.subtitle, + text = formatTime(message.timestampMillis), style = MaterialTheme.typography.labelSmall, - color = bubbleTextColor.copy(alpha = 0.75f), - maxLines = 1, - overflow = TextOverflow.Ellipsis + color = bubbleTextColor.copy(alpha = 0.7f) ) + IconButton( + onClick = onCopy, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = LanguageManager.getString("common.copied", currentLanguage), + tint = bubbleTextColor.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + } } } } - - // 消息正文 - Text( - text = message.content, - style = MaterialTheme.typography.bodyMedium, - color = bubbleTextColor - ) - - // 时间戳和复制按钮 - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = formatTime(message.timestampMillis), - style = MaterialTheme.typography.labelSmall, - color = bubbleTextColor.copy(alpha = 0.7f) - ) - IconButton( - onClick = onCopy, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Rounded.ContentCopy, - contentDescription = "复制", - tint = bubbleTextColor.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp) - ) - } - } } } } @@ -572,7 +546,7 @@ private fun MessageItem( } /** - * 设置选项卡界面,包含个人设置、服务器管理、身份安全和诊断信息。 + * 设置选项卡界面,包含个人设置、服务器管理、身份安全、语言、主题和诊断信息。 * @param modifier 修饰符 * @param state 当前的 UI 状态 * @param onDisplayNameChange 显示名称变更 @@ -586,6 +560,9 @@ private fun MessageItem( * @param onConnect 连接服务器 * @param onDisconnect 断开连接 * @param onClearMessages 清空消息 + * @param onThemeChange 切换主题 + * @param onUseDynamicColorChange 切换动态颜色 + * @param onLanguageChange 切换语言 */ @Composable private fun SettingsTab( @@ -603,84 +580,61 @@ private fun SettingsTab( onDisconnect: () -> Unit, onClearMessages: () -> Unit, onThemeChange: (String) -> Unit, - onUseDynamicColorChange: (Boolean) -> Unit + onUseDynamicColorChange: (Boolean) -> Unit, + onLanguageChange: (String) -> Unit ) { + // 定义翻译函数 t + fun t(key: String) = LanguageManager.getString(key, state.language) + LazyColumn( modifier = modifier .fillMaxSize() .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // 个人设置卡片 + // 个人设置 item { - Card { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("个人设置", style = MaterialTheme.typography.titleMedium) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( value = state.displayName, onValueChange = onDisplayNameChange, modifier = Modifier.fillMaxWidth(), - label = { Text("显示名称") }, - supportingText = { Text("最长 64 字符") }, + label = { Text(t("settings.display_name")) }, maxLines = 1 ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onConnect, enabled = state.canConnect) { - Text("连接") - } - OutlinedButton(onClick = onDisconnect, enabled = state.canDisconnect) { - Text("断开") - } - OutlinedButton(onClick = onClearMessages) { - Text("清空消息") - } + Button(onClick = onConnect, enabled = state.canConnect) { Text(t("settings.connect")) } + OutlinedButton(onClick = onDisconnect, enabled = state.canDisconnect) { Text(t("settings.disconnect")) } + OutlinedButton(onClick = onClearMessages) { Text(t("settings.clear_msg")) } } } } } - // 服务器管理卡片 + // 服务器管理 item { - Card { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("服务器", style = MaterialTheme.typography.titleMedium) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(t("settings.server"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( value = state.serverUrl, onValueChange = onServerUrlChange, modifier = Modifier.fillMaxWidth(), - label = { Text("服务器地址") }, - placeholder = { Text("ws://10.0.2.2:13173/") }, + label = { Text(t("settings.server_url")) }, maxLines = 1 ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onSaveServer) { - Text("保存地址") - } - OutlinedButton(onClick = onRemoveServer) { - Text("删除当前") - } + Button(onClick = onSaveServer) { Text(t("settings.save_server")) } + OutlinedButton(onClick = onRemoveServer) { Text(t("settings.remove_current")) } } if (state.serverUrls.isNotEmpty()) { HorizontalDivider() - Text("已保存地址", style = MaterialTheme.typography.labelLarge) + Text(t("settings.saved_servers"), style = MaterialTheme.typography.labelLarge) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(state.serverUrls) { url -> - AssistChip( - onClick = { onSelectServer(url) }, - label = { - Text( - text = url, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) + AssistChip(onClick = { onSelectServer(url) }, label = { Text(url, maxLines = 1, overflow = TextOverflow.Ellipsis) }) } } } @@ -688,80 +642,66 @@ private fun SettingsTab( } } - // 身份与安全卡片 + // 身份与安全 item { - Card { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("身份与安全", style = MaterialTheme.typography.titleMedium) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(t("settings.identity"), style = MaterialTheme.typography.titleMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = onRevealPublicKey, - enabled = !state.loadingPublicKey - ) { - Text(if (state.loadingPublicKey) "读取中" else "查看/生成公钥") - } - OutlinedButton( - onClick = onCopyPublicKey, - enabled = state.myPublicKey.isNotBlank() - ) { - Text("复制公钥") - } + Button(onClick = onRevealPublicKey, enabled = !state.loadingPublicKey) { Text(if (state.loadingPublicKey) "..." else t("settings.reveal_key")) } + OutlinedButton(onClick = onCopyPublicKey, enabled = state.myPublicKey.isNotBlank()) { Text(t("settings.copy_key")) } } - OutlinedTextField( - value = state.myPublicKey, - onValueChange = {}, - modifier = Modifier.fillMaxWidth(), - readOnly = true, - label = { Text("我的公钥") }, - placeholder = { Text("点击“查看/生成公钥”") }, - maxLines = 4 - ) + OutlinedTextField(value = state.myPublicKey, onValueChange = {}, modifier = Modifier.fillMaxWidth(), readOnly = true, label = { Text(t("settings.my_key")) }, maxLines = 4) } } } - // 主题设置卡片 + // 语言选择 item { - Card { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("主题", style = MaterialTheme.typography.titleMedium) - - // 动态颜色开关(仅 Android 12+ 显示) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Switch( - checked = state.useDynamicColor, - onCheckedChange = onUseDynamicColorChange + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(t("settings.language"), style = MaterialTheme.typography.titleMedium) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(LanguageManager.supportedLanguages) { lang -> + FilterChip( + selected = state.language == lang.code, + onClick = { onLanguageChange(lang.code) }, + label = { Text(lang.name) }, + leadingIcon = { Icon(Icons.Rounded.Language, contentDescription = null, modifier = Modifier.size(16.dp)) } ) - Text("使用动态颜色(跟随系统)") } } + } + } + } - // 当动态颜色关闭时,显示预设主题选择 + // 主题设置 + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(t("settings.theme"), style = MaterialTheme.typography.titleMedium) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Switch(checked = state.useDynamicColor, onCheckedChange = onUseDynamicColorChange) + Text(t("settings.dynamic_color")) + } + } if (!state.useDynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - Text("预设主题", style = MaterialTheme.typography.labelLarge) + Text(t("settings.preset_themes"), style = MaterialTheme.typography.labelLarge) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(themeOptions) { option -> + val themeName = when(option.id) { + "blue" -> t("theme.blue") + "gray" -> t("theme.gray") + "green" -> t("theme.green") + "red" -> t("theme.red") + else -> option.name + } FilterChip( selected = state.themeId == option.id, onClick = { onThemeChange(option.id) }, - label = { Text(option.name) }, - leadingIcon = { - Box( - modifier = Modifier - .size(16.dp) - .background(option.primary, RoundedCornerShape(4.dp)) - ) - } + label = { Text(themeName) }, + leadingIcon = { Box(modifier = Modifier.size(16.dp).background(option.primary, RoundedCornerShape(4.dp))) } ) } } @@ -770,34 +710,23 @@ private fun SettingsTab( } } - // 诊断信息卡片 + // 诊断信息 item { - Card { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("诊断", style = MaterialTheme.typography.titleMedium) - Text("连接提示:${state.statusHint}") - Text("当前状态:${state.statusText}") - Text("证书指纹:${state.certFingerprint.ifBlank { "未获取" }}") - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Switch( - checked = state.showSystemMessages, - onCheckedChange = onToggleShowSystem - ) - Text("显示系统消息") + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(t("settings.diagnostics"), style = MaterialTheme.typography.titleMedium) + Text("${t("settings.status_hint")}:${state.statusHint}") + Text("${t("settings.current_status")}:${state.statusText}") + Text("${t("settings.cert_fingerprint")}:${state.certFingerprint.ifBlank { "N/A" }}") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Switch(checked = state.showSystemMessages, onCheckedChange = onToggleShowSystem) + Text(t("settings.show_system")) } } } } - item { - Spacer(modifier = Modifier.height(12.dp)) - } + item { Spacer(modifier = Modifier.height(12.dp)) } } } @@ -812,4 +741,4 @@ private fun formatTime(tsMillis: Long): String { .atZone(ZoneId.systemDefault()) .toLocalTime() .format(formatter) -} \ No newline at end of file +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt index f8ce61d..b9a1f25 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -183,8 +183,9 @@ object ChatSessionManager { serverUrl = pref.currentServerUrl, directMode = pref.directMode, showSystemMessages = pref.showSystemMessages, - themeId = pref.themeId, // 假设 preferences 中有 themeId - useDynamicColor = pref.useDynamicColor + themeId = pref.themeId, + useDynamicColor = pref.useDynamicColor, + language = pref.language ) } // 如果上次会话启用了自动重连,则自动恢复连接 @@ -207,6 +208,17 @@ object ChatSessionManager { } } + /** + * 更新语言 + * @param language 语言代码 + */ + fun updateLanguage(language: String) { + _uiState.update { it.copy(language = language) } + scope.launch { + preferencesRepository.setLanguage(language) + } + } + /** * 更改使用动态颜色 * @param enabled 主题名 @@ -519,7 +531,7 @@ object ChatSessionManager { } /** - * 确保本地身份已加载或创建。 + * 确保本地身份已加载 or 创建。 * @return 本地身份对象 */ private suspend fun ensureIdentity(): RsaCryptoManager.Identity { @@ -533,7 +545,7 @@ object ChatSessionManager { } /** - * 处理收到的原始文本消息(可能是握手包或加密消息)。 + * 处理收到的原始文本消息(可能是握手包 or 加密消息)。 * @param rawText 原始文本 */ private suspend fun handleIncomingMessage(rawText: String) { diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt index 88ecb88..c7cc58b 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt @@ -67,6 +67,7 @@ data class UiMessage( * @property myPublicKey 本地公钥 * @property sending 是否正在发送消息(用于禁用按钮) * @property loadingPublicKey 是否正在加载公钥 + * @property language 当前选择的语言代码 (如 "zh", "en", "ja") */ data class ChatUiState( val status: ConnectionStatus = ConnectionStatus.IDLE, @@ -83,8 +84,9 @@ data class ChatUiState( val myPublicKey: String = "", val sending: Boolean = false, val loadingPublicKey: Boolean = false, - val themeId: String = "default", /* 当前选中的主题名 (@emilia-t)*/ - val useDynamicColor: Boolean = true /* 是否使用 Android 12+ 动态颜色 (@emilia-t)*/ + val themeId: String = "blue", + val useDynamicColor: Boolean = true, + val language: String = "zh" ) { /** * 是否允许连接。 diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt index d6b55ba..8b53474 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.AndroidViewModel /** * ViewModel 层,作为 UI 与 [ChatSessionManager] 的桥梁。 - * 初始化会话管理器并暴露其状态和事件流,同时提供所有用户操作的代理方法。 + * 初始化会话管理器并暴露其状态 and 事件流,同时提供所有用户操作的代理方法。 * @param application Application 实例 */ class ChatViewModel(application: Application) : AndroidViewModel(application) { @@ -34,4 +34,5 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) fun updateUseDynamicColor(enabled: Boolean) = ChatSessionManager.updateUseDynamicColor(enabled) + fun updateLanguage(language: String) = ChatSessionManager.updateLanguage(language) } \ No newline at end of file diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt index f73b859..97138d3 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt @@ -2,10 +2,16 @@ package com.onlinemsg.client.ui.theme import androidx.compose.ui.graphics.Color -// 应用的主色调、表面色、错误色等定义 -val Primary = Color(0xFF0C6D62) +// 浅色模式 - 温暖色调定义 +val Primary = Color(0xFFD84315) // 深橙色 800 val OnPrimary = Color(0xFFFFFFFF) -val Secondary = Color(0xFF4A635F) -val Surface = Color(0xFFF7FAF8) -val SurfaceVariant = Color(0xFFDCE8E4) -val Error = Color(0xFFB3261E) \ No newline at end of file +val Secondary = Color(0xFF6D4C41) // 棕色 600 +val Surface = Color(0xFFFFFBF0) // 温暖的奶油白 +val SurfaceVariant = Color(0xFFFBE9E7) // 极浅橙色 +val Error = Color(0xFFB00020) + +// 深色模式 - 温暖色调定义 +val DarkPrimary = Color(0xFFFFB5A0) // 浅暖橙 +val DarkSecondary = Color(0xFFD7C1B1) // 浅棕 +val DarkSurface = Color(0xFF1D1B16) // 暖深褐色(解决深色模式下背景过亮问题) +val OnDarkSurface = Color(0xFFEDE0DD) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt index 2843bee..2e6c09a 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt @@ -21,8 +21,11 @@ private val LightColors = lightColorScheme( ) private val DarkColors = darkColorScheme( - primary = Primary.copy(alpha = 0.9f), - secondary = Secondary.copy(alpha = 0.9f), + primary = DarkPrimary, + onPrimary = Color.Black, + secondary = DarkSecondary, + surface = DarkSurface, + onSurface = OnDarkSurface, error = Error ) @@ -30,40 +33,39 @@ private val DarkColors = darkColorScheme( * 应用主题可组合函数。 * 支持浅色/深色模式以及 Android 12+ 的动态颜色。 * @param darkTheme 是否强制深色模式(默认跟随系统) - * @param dynamicColor 是否启用动态颜色(默认 true) - * @param content 内部内容 + * @param themeId 当前选中的主题 ID (默认为 "blue") + * @param useDynamicColor 是否启用动态颜色(Android 12+ 支持) */ @Composable fun OnlineMsgTheme( darkTheme: Boolean = isSystemInDarkTheme(), - themeId: String = "default", // 从外部传入 - useDynamicColor: Boolean = true, // 从外部传入 + themeId: String = "blue", // 默认预设设为 blue + useDynamicColor: Boolean = true, content: @Composable () -> Unit ) { val context = LocalContext.current val colorScheme = when { - // 优先使用动态颜色(如果启用且系统支持) + // 1. 优先使用动态颜色(如果启用且系统支持) useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - // 否则根据主题 ID 选择预设 + // 2. 根据 themeId 选择预设 else -> { val option = themeOptions.find { it.id == themeId } ?: themeOptions.first() if (darkTheme) { darkColorScheme( - primary = option.primary, - secondary = option.secondary, - error = option.error ?: Error, - surface = option.surface ?: Surface, - // 其他颜色可以沿用默认深色调整 + primary = option.primaryDark ?: option.primary.copy(alpha = 0.8f), + secondary = option.secondary.copy(alpha = 0.8f), + surface = option.surfaceDark ?: DarkSurface, + onSurface = OnDarkSurface, + error = option.error ?: Error ) } else { lightColorScheme( primary = option.primary, secondary = option.secondary, - error = option.error ?: Error, surface = option.surface ?: Surface, - // 其他颜色保持默认浅色 + error = option.error ?: Error ) } } @@ -83,13 +85,38 @@ data class ThemeOption( val primary: Color, val secondary: Color, val surface: Color? = null, + val primaryDark: Color? = null, // 显式深色主色 + val surfaceDark: Color? = null, // 显式深色背景 val error: Color? = null ) // 预设主题列表 val themeOptions = listOf( - ThemeOption("default", "默认", Primary, Secondary), - ThemeOption("blue", "蔚蓝", Color(0xFF1E88E5), Color(0xFF6A8DAA)), - ThemeOption("green", "翠绿", Color(0xFF2E7D32), Color(0xFF4A635F)), - ThemeOption("red", "绯红", Color(0xFFC62828), Color(0xFF8D6E63)) -) \ No newline at end of file + // 默认列表首位即为默认主题 + ThemeOption( + id = "blue", + name = "蔚蓝", + primary = Color(0xFF1E88E5), + secondary = Color(0xFF6A8DAA) + ), + ThemeOption( + id = "gray", + name = "商务灰", + primary = Color(0xFF607D8B), + secondary = Color(0xFF90A4AE), + primaryDark = Color(0xFFCFD8DC), + surfaceDark = Color(0xFF263238) + ), + ThemeOption( + id = "green", + name = "翠绿", + primary = Color(0xFF2E7D32), + secondary = Color(0xFF4A635F) + ), + ThemeOption( + id = "red", + name = "绯红", + primary = Color(0xFFC62828), + secondary = Color(0xFF8D6E63) + ) +) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt new file mode 100644 index 0000000..355c591 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt @@ -0,0 +1,188 @@ +package com.onlinemsg.client.util + +/** + * 语言管理类,统一存储应用内的多语言词条。 + * 类似于 Minecraft 的语言文件映射。 + */ +object LanguageManager { + + private val translations = mapOf( + "zh" to mapOf( + "tab.chat" to "聊天", + "tab.settings" to "设置", + "settings.personal" to "个人设置", + "settings.display_name" to "显示名称", + "settings.server" to "服务器", + "settings.server_url" to "服务器地址", + "settings.save_server" to "保存地址", + "settings.remove_current" to "删除当前", + "settings.saved_servers" to "已保存地址", + "settings.identity" to "身份与安全", + "settings.reveal_key" to "查看/生成公钥", + "settings.copy_key" to "复制公钥", + "settings.my_key" to "我的公钥", + "settings.theme" to "主题", + "settings.preset_themes" to "预设主题", + "settings.language" to "语言", + "settings.diagnostics" to "诊断", + "settings.status_hint" to "连接提示", + "settings.current_status" to "当前状态", + "settings.cert_fingerprint" to "证书指纹", + "settings.show_system" to "显示系统消息", + "settings.connect" to "连接", + "settings.disconnect" to "断开", + "settings.clear_msg" to "清空消息", + "settings.dynamic_color" to "使用动态颜色", + "chat.broadcast" to "广播", + "chat.private" to "私聊", + "chat.target_key" to "目标公钥", + "chat.input_placeholder" to "输入消息", + "chat.send" to "发送", + "chat.sending" to "发送中", + "chat.empty_hint" to "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。", + "common.copied" to "已复制", + "common.unknown" to "未知", + "theme.blue" to "蔚蓝", + "theme.gray" to "商务灰", + "theme.green" to "翠绿", + "theme.red" to "绯红", + "theme.warm" to "温暖" + ), + "en" to mapOf( + "tab.chat" to "Chat", + "tab.settings" to "Settings", + "settings.personal" to "Personal", + "settings.display_name" to "Display Name", + "settings.server" to "Server", + "settings.server_url" to "Server Address", + "settings.save_server" to "Save", + "settings.remove_current" to "Remove", + "settings.saved_servers" to "Saved Addresses", + "settings.identity" to "Identity & Security", + "settings.reveal_key" to "Reveal/Generate Key", + "settings.copy_key" to "Copy Key", + "settings.my_key" to "My Public Key", + "settings.theme" to "Theme", + "settings.preset_themes" to "Preset Themes", + "settings.language" to "Language", + "settings.diagnostics" to "Diagnostics", + "settings.status_hint" to "Hint", + "settings.current_status" to "Status", + "settings.cert_fingerprint" to "Fingerprint", + "settings.show_system" to "Show System Messages", + "settings.connect" to "Link", + "settings.disconnect" to "Dislink", + "settings.clear_msg" to "ClearMsg", + "settings.dynamic_color" to "Use dynamic color", + "chat.broadcast" to "Broadcast", + "chat.private" to "Private", + "chat.target_key" to "Target Public Key", + "chat.input_placeholder" to "Type a message", + "chat.send" to "Send", + "chat.sending" to "Sending", + "chat.empty_hint" to "Connect to start chatting. Default is broadcast.", + "common.copied" to "Copied", + "common.unknown" to "Unknown", + "theme.blue" to "Blue", + "theme.gray" to "Business Gray", + "theme.green" to "Green", + "theme.red" to "Red", + "theme.warm" to "Warm" + ), + "ja" to mapOf( + "tab.chat" to "チャット", + "tab.settings" to "設定", + "settings.personal" to "個人設定", + "settings.display_name" to "表示名", + "settings.server" to "サーバー", + "settings.server_url" to "アドレス", + "settings.save_server" to "保存", + "settings.remove_current" to "削除", + "settings.saved_servers" to "保存済みアドレス", + "settings.identity" to "セキュリティ", + "settings.reveal_key" to "公開鍵を表示/生成", + "settings.copy_key" to "鍵をコピー", + "settings.my_key" to "マイ公開鍵", + "settings.theme" to "テーマ", + "settings.preset_themes" to "プリセット", + "settings.language" to "言語", + "settings.diagnostics" to "診断", + "settings.status_hint" to "ヒント", + "settings.current_status" to "ステータス", + "settings.cert_fingerprint" to "証明書指紋", + "settings.show_system" to "システムメッセージを表示", + "settings.connect" to "接続", + "settings.disconnect" to "切断", + "settings.clear_msg" to "履歴を消去", + "settings.dynamic_color" to "動的カラーを使用", + "chat.broadcast" to "全体", + "chat.private" to "個人", + "chat.target_key" to "相手の公開鍵", + "chat.input_placeholder" to "メッセージを入力", + "chat.send" to "送信", + "chat.sending" to "送信中", + "chat.empty_hint" to "接続するとチャットを開始できます。", + "common.copied" to "コピーしました", + "common.unknown" to "不明", + "theme.blue" to "ブルー", + "theme.gray" to "ビジネスグレー", + "theme.green" to "グリーン", + "theme.red" to "レッド", + "theme.warm" to "ウォーム" + ), + "ko" to mapOf( + "tab.chat" to "채팅", + "tab.settings" to "설정", + "settings.personal" to "개인 설정", + "settings.display_name" to "표시 이름", + "settings.server" to "서버", + "settings.server_url" to "서버 주소", + "settings.save_server" to "주소 저장", + "settings.remove_current" to "현재 삭제", + "settings.saved_servers" to "저장된 주소", + "settings.identity" to "신원 및 보안", + "settings.reveal_key" to "공개키 보기/생성", + "settings.copy_key" to "공개키 복사", + "settings.my_key" to "나의 공개키", + "settings.theme" to "테마", + "settings.preset_themes" to "프리셋 테마", + "settings.language" to "언어", + "settings.diagnostics" to "진단", + "settings.status_hint" to "연결 힌트", + "settings.current_status" to "현재 상태", + "settings.cert_fingerprint" to "인증서 지문", + "settings.show_system" to "시스템 메시지 표시", + "settings.connect" to "연결", + "settings.disconnect" to "연결 끊기", + "settings.clear_msg" to "정보 삭제", + "settings.dynamic_color" to "동적 색상 사용", + "chat.broadcast" to "브로드캐스트", + "chat.private" to "비공개 채팅", + "chat.target_key" to "대상 공개키", + "chat.input_placeholder" to "메시지 입력", + "chat.send" to "전송", + "chat.sending" to "전송 중", + "chat.empty_hint" to "연결 후 채팅이 가능합니다. 기본은 브로드캐스트이며, 비공개 채팅으로 전환 후 대상 공개키를 입력할 수 있습니다.", + "common.copied" to "복사됨", + "common.unknown" to "알 수 없음", + "theme.blue" to "파랑", + "theme.gray" to "비즈니스 그레이", + "theme.green" to "초록", + "theme.red" to "빨강", + "theme.warm" to "따뜻함" + ) + ) + + fun getString(key: String, lang: String): String { + return translations[lang]?.get(key) ?: translations["en"]?.get(key) ?: key + } + + val supportedLanguages = listOf( + LanguageOption("zh", "中文"), + LanguageOption("en", "English"), + LanguageOption("ja", "日本语"), + LanguageOption("ko", "한국어") + ) +} + +data class LanguageOption(val code: String, val name: String)