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/.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 a769b8f..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"
+ 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 2ad6213..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
@@ -21,7 +21,10 @@ data class UserPreferences(
val currentServerUrl: String,
val showSystemMessages: Boolean,
val directMode: Boolean,
- val shouldAutoReconnect: Boolean
+ val shouldAutoReconnect: Boolean,
+ val themeId: String = "blue",
+ val useDynamicColor: Boolean = true,
+ val language: String = "zh" // 默认中文
)
class UserPreferencesRepository(
@@ -44,10 +47,31 @@ 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] ?: "blue",
+ useDynamicColor = prefs[KEY_USE_DYNAMIC_COLOR] ?: true,
+ language = prefs[KEY_LANGUAGE] ?: "zh"
)
}
+ suspend fun setThemeId(themeId: String) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_THEME_ID] = themeId
+ }
+ }
+
+ suspend fun setLanguage(language: String) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_LANGUAGE] = language
+ }
+ }
+
+ 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 +152,8 @@ 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")
+ 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 a8e31a4..aac72a4 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
@@ -29,6 +30,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
@@ -49,6 +53,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
@@ -66,26 +71,47 @@ 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.util.LanguageManager
-private enum class MainTab(val label: String) {
- CHAT("聊天"),
- SETTINGS("设置")
+
+/**
+ * 主界面底部导航栏的选项卡枚举。
+ */
+private enum class MainTab(val labelKey: String) {
+ CHAT("tab.chat"),
+ SETTINGS("tab.settings")
}
+/**
+ * 应用程序的根可组合函数。
+ * 集成 ViewModel、主题、Scaffold 以及选项卡切换逻辑。
+ * @param viewModel 由 [viewModel] 自动提供的 [ChatViewModel] 实例
+ */
@Composable
-fun OnlineMsgApp(
- viewModel: ChatViewModel = viewModel()
-) {
- OnlineMsgTheme {
- val state by viewModel.uiState.collectAsStateWithLifecycle()
+fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) {
+
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ OnlineMsgTheme(
+ darkTheme = isSystemInDarkTheme(), // 仍可跟随系统
+ themeId = state.themeId,
+ useDynamicColor = state.useDynamicColor
+ )
+ {
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 ->
when (event) {
@@ -110,18 +136,36 @@ fun OnlineMsgApp(
)
},
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)
+ )
+ }
)
}
},
@@ -164,7 +208,10 @@ fun OnlineMsgApp(
viewModel.onMessageCopied()
}
},
- onClearMessages = viewModel::clearMessages
+ onClearMessages = viewModel::clearMessages,
+ onThemeChange = viewModel::updateTheme,
+ onUseDynamicColorChange = viewModel::updateUseDynamicColor,
+ onLanguageChange = viewModel::updateLanguage
)
}
}
@@ -172,6 +219,11 @@ fun OnlineMsgApp(
}
}
+/**
+ * 应用程序顶部栏,显示标题和当前连接状态徽章。
+ * @param statusText 状态文本
+ * @param statusColor 状态指示点的颜色
+ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppTopBar(
@@ -186,8 +238,8 @@ private fun AppTopBar(
TopAppBar(
title = {
Text(
- text = "OnlineMsg Chat",
- style = MaterialTheme.typography.titleLarge
+ text = "OnlineMsg",
+ style = MaterialTheme.typography.titleMedium
)
},
actions = {
@@ -203,17 +255,26 @@ 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
+ )
)
}
+/**
+ * 聊天选项卡的主界面。
+ * 包含模式切换、消息列表和输入区域。
+ */
@Composable
private fun ChatTab(
modifier: Modifier,
@@ -225,7 +286,11 @@ private fun ChatTab(
onCopyMessage: (String) -> Unit
) {
val listState = rememberLazyListState()
+
+ // 定义翻译函数 t
+ fun t(key: String) = LanguageManager.getString(key, state.language)
+ // 当消息列表新增消息时,自动滚动到底部
LaunchedEffect(state.visibleMessages.size) {
if (state.visibleMessages.isNotEmpty()) {
listState.animateScrollToItem(state.visibleMessages.lastIndex)
@@ -236,19 +301,26 @@ 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
) {
- 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")) }
)
+
+ // 在这一行腾出的空间可以放置其他快捷操作,或者保持简洁
}
Spacer(modifier = Modifier.height(8.dp))
@@ -259,28 +331,30 @@ private fun ChatTab(
)
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(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
state = listState,
- contentPadding = PaddingValues(vertical = 8.dp),
+ contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (state.visibleMessages.isEmpty()) {
+ // 无消息时显示提示卡片
item {
Card(
colors = CardDefaults.cardColors(
@@ -288,7 +362,7 @@ private fun ChatTab(
)
) {
Text(
- text = "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。",
+ text = t("chat.empty_hint"),
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium
)
@@ -298,7 +372,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
)
}
}
@@ -306,6 +381,7 @@ private fun ChatTab(
Spacer(modifier = Modifier.height(8.dp))
+ // 消息输入区域
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -314,7 +390,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(
@@ -327,150 +403,179 @@ 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 "发送")
+ Text(if (state.sending) "..." else t("chat.send"))
}
}
}
}
+/**
+ * 单个消息气泡组件。
+ */
+@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)
- )
- }
- }
}
}
}
}
}
+/**
+ * 设置选项卡界面,包含个人设置、服务器管理、身份安全、语言、主题和诊断信息。
+ * @param modifier 修饰符
+ * @param state 当前的 UI 状态
+ * @param onDisplayNameChange 显示名称变更
+ * @param onServerUrlChange 服务器地址变更
+ * @param onSaveServer 保存当前服务器地址
+ * @param onRemoveServer 删除当前服务器地址
+ * @param onSelectServer 选择历史服务器地址
+ * @param onToggleShowSystem 切换显示系统消息
+ * @param onRevealPublicKey 显示/生成公钥
+ * @param onCopyPublicKey 复制公钥
+ * @param onClearMessages 清空消息
+ * @param onThemeChange 切换主题
+ * @param onUseDynamicColorChange 切换动态颜色
+ * @param onLanguageChange 切换语言
+ */
@Composable
private fun SettingsTab(
modifier: Modifier,
@@ -483,8 +588,13 @@ private fun SettingsTab(
onToggleShowSystem: (Boolean) -> Unit,
onRevealPublicKey: () -> Unit,
onCopyPublicKey: () -> Unit,
- onClearMessages: () -> Unit
+ onClearMessages: () -> Unit,
+ onThemeChange: (String) -> Unit,
+ onUseDynamicColorChange: (Boolean) -> Unit,
+ onLanguageChange: (String) -> Unit
) {
+ fun t(key: String) = LanguageManager.getString(key, state.language)
+
val settingsCardModifier = Modifier.fillMaxWidth()
val settingsCardContentModifier = Modifier
.fillMaxWidth()
@@ -504,13 +614,12 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
- Text("个人设置", style = MaterialTheme.typography.titleMedium)
+ 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
)
}
@@ -525,7 +634,7 @@ private fun SettingsTab(
) {
Text("聊天数据", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = onClearMessages) {
- Text("清空聊天记录")
+ Text(t("settings.clear_msg"))
}
}
}
@@ -537,37 +646,26 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
- Text("服务器", style = MaterialTheme.typography.titleMedium)
+ 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
- )
- }
+ label = { Text(url, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
}
}
@@ -582,19 +680,16 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
- Text("身份与安全", style = MaterialTheme.typography.titleMedium)
+ 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 "查看/生成公钥")
+ Button(onClick = onRevealPublicKey, enabled = !state.loadingPublicKey) {
+ Text(if (state.loadingPublicKey) "..." else t("settings.reveal_key"))
}
OutlinedButton(
onClick = onCopyPublicKey,
enabled = state.myPublicKey.isNotBlank()
) {
- Text("复制公钥")
+ Text(t("settings.copy_key"))
}
}
OutlinedTextField(
@@ -602,8 +697,7 @@ private fun SettingsTab(
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
readOnly = true,
- label = { Text("我的公钥") },
- placeholder = { Text("点击“查看/生成公钥”") },
+ label = { Text(t("settings.my_key")) },
maxLines = 4
)
}
@@ -616,30 +710,106 @@ private fun SettingsTab(
modifier = settingsCardContentModifier,
verticalArrangement = settingsCardContentSpacing
) {
- Text("诊断", style = MaterialTheme.typography.titleMedium)
- Text("连接提示:${state.statusHint}")
- Text("当前状态:${state.statusText}")
- Text("证书指纹:${state.certFingerprint.ifBlank { "未获取" }}")
+ 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)
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Card(modifier = settingsCardModifier) {
+ Column(
+ modifier = settingsCardContentModifier,
+ verticalArrangement = settingsCardContentSpacing
+ ) {
+ 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(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(themeName) },
+ leadingIcon = {
+ Box(
+ modifier = Modifier
+ .size(16.dp)
+ .background(option.primary, RoundedCornerShape(4.dp))
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Card(modifier = settingsCardModifier) {
+ Column(
+ modifier = settingsCardContentModifier,
+ verticalArrangement = settingsCardContentSpacing
+ ) {
+ 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("显示系统消息")
+ 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)) }
}
}
+/**
+ * 将时间戳格式化为本地时间的小时:分钟(如 "14:30")。
+ * @param tsMillis 毫秒时间戳
+ * @return 格式化后的时间字符串
+ */
private fun formatTime(tsMillis: Long): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm")
return Instant.ofEpochMilli(tsMillis)
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..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
@@ -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,13 @@ object ChatSessionManager {
serverUrls = pref.serverUrls,
serverUrl = pref.currentServerUrl,
directMode = pref.directMode,
- showSystemMessages = pref.showSystemMessages
+ showSystemMessages = pref.showSystemMessages,
+ themeId = pref.themeId,
+ useDynamicColor = pref.useDynamicColor,
+ language = pref.language
)
}
+ // 如果上次会话启用了自动重连,则自动恢复连接
if (pref.shouldAutoReconnect && !autoReconnectTriggered) {
autoReconnectTriggered = true
ChatForegroundService.start(application)
@@ -180,6 +197,43 @@ object ChatSessionManager {
}
}
+ /**
+ * 更新主题
+ * @param themeId 主题名
+ */
+ fun updateTheme(themeId: String) {
+ _uiState.update { it.copy(themeId = themeId) }
+ scope.launch {
+ preferencesRepository.setThemeId(themeId)
+ }
+ }
+
+ /**
+ * 更新语言
+ * @param language 语言代码
+ */
+ fun updateLanguage(language: String) {
+ _uiState.update { it.copy(language = language) }
+ scope.launch {
+ preferencesRepository.setLanguage(language)
+ }
+ }
+
+ /**
+ * 更改使用动态颜色
+ * @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 +242,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 +277,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 切换是否显示系统消息并持久化。
+ * @param show true 显示
+ */
fun toggleShowSystemMessages(show: Boolean) {
_uiState.update { it.copy(showSystemMessages = show) }
scope.launch {
@@ -214,11 +288,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 +323,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 从历史列表中移除当前服务器地址。
+ * 如果列表清空则恢复默认地址。
+ */
fun removeCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) return
@@ -268,6 +352,9 @@ object ChatSessionManager {
}
}
+ /**
+ * 加载或生成本地身份密钥对,并将公钥显示到 UI。
+ */
fun revealMyPublicKey() {
scope.launch {
_uiState.update { it.copy(loadingPublicKey = true) }
@@ -287,10 +374,17 @@ object ChatSessionManager {
}
}
+ /**
+ * 主动连接服务器(由用户点击连接触发)。
+ */
fun connect() {
connectInternal(isAutoRestore = false)
}
+ /**
+ * 内部连接逻辑,区分自动恢复和手动连接。
+ * @param isAutoRestore 是否为应用启动时的自动恢复连接
+ */
private fun connectInternal(isAutoRestore: Boolean) {
if (!initialized) return
val state = _uiState.value
@@ -339,6 +433,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 主动断开连接。
+ * @param stopService 是否同时停止前台服务(默认 true)
+ */
fun disconnect(stopService: Boolean = true) {
manualClose = true
cancelReconnect()
@@ -362,6 +460,10 @@ object ChatSessionManager {
addSystemMessage("已断开连接")
}
+ /**
+ * 发送消息(广播或私聊)。
+ * 执行签名、加密并发送。
+ */
fun sendMessage() {
val current = _uiState.value
if (!current.canSend) return
@@ -419,12 +521,19 @@ object ChatSessionManager {
}
}
+ /**
+ * 消息复制成功后的回调,显示“已复制”提示。
+ */
fun onMessageCopied() {
scope.launch {
_events.emit(UiEvent.ShowSnackbar("已复制"))
}
}
+ /**
+ * 确保本地身份已加载 or 创建。
+ * @return 本地身份对象
+ */
private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
return identityMutex.withLock {
identity ?: withContext(Dispatchers.Default) {
@@ -435,6 +544,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 处理收到的原始文本消息(可能是握手包 or 加密消息)。
+ * @param rawText 原始文本
+ */
private suspend fun handleIncomingMessage(rawText: String) {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
_uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
@@ -445,7 +558,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 +574,7 @@ object ChatSessionManager {
return
}
+ // 尝试解析为带外层的 EnvelopeDto
val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull()
if (plain?.type == "publickey") {
cancelHelloTimeout()
@@ -480,6 +594,7 @@ object ChatSessionManager {
return
}
+ // 握手阶段收到非预期消息则报错
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
_uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
@@ -492,6 +607,7 @@ object ChatSessionManager {
addSystemMessage("握手包解析失败:$preview")
}
+ // 尝试解密(若已握手完成,收到的应是加密消息)
val id = ensureIdentity()
val decrypted = runCatching {
withContext(Dispatchers.Default) {
@@ -509,6 +625,10 @@ object ChatSessionManager {
handleSecureMessage(secure)
}
+ /**
+ * 处理服务端发来的握手 Hello 数据。
+ * @param hello 服务端公钥和挑战
+ */
private suspend fun handleServerHello(hello: HelloDataDto) {
cancelHelloTimeout()
serverPublicKey = hello.publicKey
@@ -552,6 +672,10 @@ object ChatSessionManager {
}
}
+ /**
+ * 发送认证消息(包含签名后的身份信息)。
+ * @param challenge 服务端提供的挑战值
+ */
private suspend fun sendAuth(challenge: String) {
val id = ensureIdentity()
val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
@@ -595,6 +719,10 @@ object ChatSessionManager {
check(socketClient.send(cipher)) { "连接不可用" }
}
+ /**
+ * 处理安全通道建立后的业务消息(广播、私聊、认证结果等)。
+ * @param message 解密后的 EnvelopeDto
+ */
private fun handleSecureMessage(message: EnvelopeDto) {
when (message.type) {
"auth_ok" -> {
@@ -634,6 +762,11 @@ object ChatSessionManager {
}
}
+ /**
+ * 处理 WebSocket 连接关闭事件。
+ * @param code 关闭状态码
+ * @param reason 关闭原因
+ */
private fun handleSocketClosed(code: Int, reason: String) {
cancelHelloTimeout()
cancelAuthTimeout()
@@ -652,6 +785,7 @@ object ChatSessionManager {
val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
+ // 尝试切换 ws/wss 协议重试(仅限非就绪状态)
if (allowFallback) {
val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
if (fallbackUrl.isNotBlank()) {
@@ -680,6 +814,10 @@ object ChatSessionManager {
scheduleReconnect("连接已中断")
}
+ /**
+ * 添加一条系统消息(自动按 TTL 过期)。
+ * @param content 消息内容
+ */
private fun addSystemMessage(content: String) {
val message = UiMessage(
role = MessageRole.SYSTEM,
@@ -692,6 +830,13 @@ object ChatSessionManager {
scheduleSystemMessageExpiry(message.id)
}
+ /**
+ * 添加一条接收到的用户消息。
+ * @param sender 发送者名称
+ * @param subtitle 附加说明(如私聊来源)
+ * @param content 消息内容
+ * @param channel 消息通道(广播/私聊)
+ */
private fun addIncomingMessage(
sender: String,
subtitle: String,
@@ -713,6 +858,12 @@ object ChatSessionManager {
)
}
+ /**
+ * 添加一条发出的消息。
+ * @param content 消息内容
+ * @param subtitle 附加说明(如私聊目标)
+ * @param channel 消息通道
+ */
private fun addOutgoingMessage(
content: String,
subtitle: String,
@@ -729,6 +880,10 @@ object ChatSessionManager {
)
}
+ /**
+ * 将消息追加到列表尾部,并清理超出数量限制的消息。
+ * @param message 要追加的消息
+ */
private fun appendMessage(message: UiMessage) {
_uiState.update { current ->
val next = (current.messages + message).takeLast(MAX_MESSAGES)
@@ -741,11 +896,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 +955,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 +979,17 @@ object ChatSessionManager {
}
}
+ /**
+ * 取消所有系统消息的过期任务。
+ */
private fun cancelSystemMessageExpiryJobs() {
systemMessageExpiryJobs.values.forEach { it.cancel() }
systemMessageExpiryJobs.clear()
}
+ /**
+ * 启动握手超时计时器。
+ */
private fun startHelloTimeout() {
cancelHelloTimeout()
helloTimeoutJob = scope.launch {
@@ -833,21 +1008,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 +1055,9 @@ object ChatSessionManager {
}
}
+ /**
+ * 关闭所有资源(用于应用退出时)。
+ */
fun shutdownAll() {
cancelSystemMessageExpiryJobs()
cancelReconnect()
@@ -871,6 +1066,9 @@ object ChatSessionManager {
socketClient.shutdown()
}
+ /**
+ * 前台服务停止时的回调。
+ */
fun onForegroundServiceStopped() {
keepAliveRequested = false
if (_uiState.value.status != ConnectionStatus.IDLE) {
@@ -882,8 +1080,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 +1102,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 +1138,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..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
@@ -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,24 @@ 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 是否正在加载公钥
+ * @property language 当前选择的语言代码 (如 "zh", "en", "ja")
+ */
data class ChatUiState(
val status: ConnectionStatus = ConnectionStatus.IDLE,
val statusHint: String = "点击连接开始聊天",
@@ -46,20 +83,35 @@ data class ChatUiState(
val certFingerprint: String = "",
val myPublicKey: String = "",
val sending: Boolean = false,
- val loadingPublicKey: Boolean = false
+ val loadingPublicKey: Boolean = false,
+ val themeId: String = "blue",
+ val useDynamicColor: Boolean = true,
+ val language: String = "zh"
) {
+ /**
+ * 是否允许连接。
+ */
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 +122,9 @@ data class ChatUiState(
ConnectionStatus.ERROR -> "异常断开"
}
+ /**
+ * 根据当前模式(广播/私聊)和是否显示系统消息,过滤出要显示的消息列表。
+ */
val visibleMessages: List
get() = messages.filter { item ->
if (item.role == MessageRole.SYSTEM) {
@@ -81,6 +136,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..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
@@ -3,6 +3,11 @@ package com.onlinemsg.client.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
+/**
+ * ViewModel 层,作为 UI 与 [ChatSessionManager] 的桥梁。
+ * 初始化会话管理器并暴露其状态 and 事件流,同时提供所有用户操作的代理方法。
+ * @param application Application 实例
+ */
class ChatViewModel(application: Application) : AndroidViewModel(application) {
init {
@@ -26,4 +31,8 @@ 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)
+ 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 0a1e4b6..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,9 +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)
+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 df0d31d..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
@@ -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,
@@ -20,25 +21,54 @@ 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
)
+/**
+ * 应用主题可组合函数。
+ * 支持浅色/深色模式以及 Android 12+ 的动态颜色。
+ * @param darkTheme 是否强制深色模式(默认跟随系统)
+ * @param themeId 当前选中的主题 ID (默认为 "blue")
+ * @param useDynamicColor 是否启用动态颜色(Android 12+ 支持)
+ */
@Composable
fun OnlineMsgTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
- dynamicColor: Boolean = true,
+ themeId: String = "blue", // 默认预设设为 blue
+ 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
+ // 1. 优先使用动态颜色(如果启用且系统支持)
+ useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
-
- darkTheme -> DarkColors
- else -> LightColors
+ // 2. 根据 themeId 选择预设
+ else -> {
+ val option = themeOptions.find { it.id == themeId } ?: themeOptions.first()
+ if (darkTheme) {
+ darkColorScheme(
+ 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,
+ surface = option.surface ?: Surface,
+ error = option.error ?: Error
+ )
+ }
+ }
}
MaterialTheme(
@@ -47,3 +77,46 @@ fun OnlineMsgTheme(
content = content
)
}
+
+// 主题选项数据类
+data class ThemeOption(
+ val id: String,
+ val name: String,
+ 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(
+ 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/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
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)