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