Merge remote-tracking branch 'origin/emilia-t'

# Conflicts:
#	android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt
pull/2/head
alimu 2 weeks ago
commit ef58ef4b80

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$/android-client" />
<option name="gradleJvm" value="21" />
</GradleProjectSettings>
</option>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

@ -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 {

@ -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<Boolean> = booleanPreferencesKey("show_system_messages")
val KEY_DIRECT_MODE: Preferences.Key<Boolean> = booleanPreferencesKey("direct_mode")
val KEY_SHOULD_AUTO_RECONNECT: Preferences.Key<Boolean> = booleanPreferencesKey("should_auto_reconnect")
val KEY_THEME_ID: Preferences.Key<String> = stringPreferencesKey("theme_id")
val KEY_USE_DYNAMIC_COLOR: Preferences.Key<Boolean> = booleanPreferencesKey("use_dynamic_color")
val KEY_LANGUAGE: Preferences.Key<String> = stringPreferencesKey("language")
}
}

@ -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)

@ -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<UiEvent>()
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<String, Job> = 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<String, Job> = 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<JsonElement>(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<EnvelopeDto>(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"
}
}

@ -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<UiMessage>
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
}
}

@ -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)
}

@ -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)

@ -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)
)
)

@ -2,4 +2,8 @@ package com.onlinemsg.client.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()
/**
* 应用程序的默认排版样式
* 使用 Material3 默认排版可根据需要自定义
*/
val AppTypography = Typography()

@ -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)
Loading…
Cancel
Save