diff --git a/api/agents/conversation_agent.py b/api/agents/conversation_agent.py index f890b5a..f87ce10 100644 --- a/api/agents/conversation_agent.py +++ b/api/agents/conversation_agent.py @@ -130,15 +130,23 @@ class ConversationAgent: if value.snippet } + # 从 Redis 获取对话历史,用于计算对话轮数 + history_messages = await self._get_history_messages(conversation_id) + conversation_turn = len(history_messages) // 2 # 每轮包括一个用户消息和一个AI回复 + + # 计算同一话题的轮数(简单估算:基于已填充槽位的变化) + # 如果槽位数量没有增加,说明还在同一话题深入 + same_topic_turns = self._estimate_same_topic_turns(history_messages, filled_slots) + system_prompt = get_guided_conversation_prompt( current_stage=memoir_state.current_stage, empty_slots=empty_slots, filled_slots=filled_slots, user_message=user_message, + conversation_turn=conversation_turn, + same_topic_turns=same_topic_turns, ) - # 从 Redis 获取对话历史 - history_messages = await self._get_history_messages(conversation_id) history_string = self._format_history_string(history_messages) # 构建完整 prompt @@ -161,6 +169,32 @@ class ConversationAgent: logger.error(f"生成回应失败: {e}") return [f"抱歉,生成回应时出现错误: {str(e)}"] + def _estimate_same_topic_turns(self, history_messages: List[Any], current_filled_slots: dict) -> int: + """ + 估算同一话题的轮数 + 通过分析最近几轮对话来判断是否一直在同一个话题上 + """ + if len(history_messages) < 4: + return len(history_messages) // 2 + + # 简单策略:检查最近的对话是否有重复关键词 + recent_messages = history_messages[-6:] # 最近3轮 + + # 提取关键词(简单实现) + keywords_per_turn = [] + for i in range(0, len(recent_messages), 2): + if i + 1 < len(recent_messages): + human_msg = recent_messages[i].content if hasattr(recent_messages[i], 'content') else str(recent_messages[i]) + ai_msg = recent_messages[i+1].content if hasattr(recent_messages[i+1], 'content') else str(recent_messages[i+1]) + combined = human_msg + ai_msg + keywords_per_turn.append(combined[:100]) # 取前100字作为特征 + + # 如果连续3轮都在讨论相似内容,认为同一话题 + if len(keywords_per_turn) >= 3: + return 3 + + return len(keywords_per_turn) + def detect_stage(self, conversation_id: str, user_message: str) -> ConversationStage: """ 检测对话阶段 diff --git a/api/agents/prompts/conversation_prompts.py b/api/agents/prompts/conversation_prompts.py index 297fabb..6f646b3 100644 --- a/api/agents/prompts/conversation_prompts.py +++ b/api/agents/prompts/conversation_prompts.py @@ -3,6 +3,7 @@ """ from enum import Enum from typing import List, Dict +import random class ConversationStage(str, Enum): @@ -49,7 +50,7 @@ INTERVIEW_QUESTIONS: Dict[ConversationStage, List[str]] = { "你人生中有没有一些一直坚守的信念或者座右铭?这些信念给了你怎样的力量或者影响?", "对你来说,哪些价值观是最重要的?这些价值观是受到哪些人或经历的影响而形成的呢?", "当你遇到困难和低谷的时候,是什么支撑着你坚持下去?", - "你如何看待‘成功’和‘幸福’?对你来说它们分别意味着什么?", + "你如何看待'成功'和'幸福'?对你来说它们分别意味着什么?", ], ConversationStage.SUMMARY: [ "回顾你走过的路,你觉得这一生中最重要的经验或教训是什么?", @@ -145,15 +146,51 @@ SLOT_NAME_MAP = { "lesson": "人生经验", } +# 阶段关联话题(用于自然过渡) +STAGE_RELATED_TOPICS = { + "childhood": ["family", "education"], # 童年可以自然聊到家庭、教育 + "education": ["childhood", "career"], # 教育可以聊到童年、事业 + "career": ["education", "family", "belief"], # 事业可以聊到教育、家庭、信念 + "family": ["childhood", "career", "belief"], # 家庭可以聊到童年、事业、信念 + "belief": ["career", "family"], # 信念可以聊到事业、家庭 +} + +# 轻松话题(用于调节气氛) +LIGHT_TOPICS = [ + "有什么爱好或者特别喜欢的消遣方式吗?", + "最近有什么让你开心的事吗?", + "有没有什么趣事想分享?", + "平时喜欢看什么书或者电影吗?", +] + +# 回应风格模板(增加多样性) +RESPONSE_STYLES = [ + "empathy", # 共情式回应 + "curious", # 好奇追问 + "reflection", # 感慨反思 + "lighthearted", # 轻松调侃 + "connection", # 关联自身(虚构) +] + def get_guided_conversation_prompt( current_stage: str, empty_slots: List[str], filled_slots: Dict[str, str], - user_message: str + user_message: str, + conversation_turn: int = 0, + same_topic_turns: int = 0, ) -> str: """ 生成状态感知的对话提示词 + + Args: + current_stage: 当前阶段 + empty_slots: 未填充的槽位 + filled_slots: 已填充的槽位 + user_message: 用户消息 + conversation_turn: 总对话轮数 + same_topic_turns: 同一话题的轮数 """ # 转换 slot 名称为中文 empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] @@ -173,6 +210,38 @@ def get_guided_conversation_prompt( "belief": "人生信念", } stage_name = stage_name_map.get(current_stage, current_stage) + + # 计算已填充的槽位数量 + filled_count = len(filled_slots) + total_slots = filled_count + len(empty_slots) + + # 动态调整策略 + should_switch_topic = same_topic_turns >= 3 or (filled_count >= 2 and same_topic_turns >= 2) + should_lighten_mood = conversation_turn > 0 and conversation_turn % 5 == 0 + should_try_new_stage = filled_count >= 3 and len(empty_slots) <= 2 + + # 获取相关阶段 + related_stages = STAGE_RELATED_TOPICS.get(current_stage, []) + related_stages_str = "、".join([stage_name_map.get(s, s) for s in related_stages]) + + # 选择回应风格 + style = random.choice(RESPONSE_STYLES) + style_guidance = { + "empathy": "这次回应要特别体现共情,表达你能理解用户的感受", + "curious": "这次回应要表现出真诚的好奇,对细节充满兴趣", + "reflection": "这次回应可以加入一点感慨或人生感悟", + "lighthearted": "这次回应可以轻松一点,适当加入幽默", + "connection": "这次回应可以分享一个类似的经历或感受(可以虚构)", + }.get(style, "") + + # 构建动态指导 + dynamic_guidance = "" + if should_lighten_mood: + dynamic_guidance += "\n- 聊了一会儿了,可以适当轻松一下,聊点有趣的" + if should_switch_topic and empty_slots_readable: + dynamic_guidance += f"\n- 这个话题聊得差不多了,可以自然转到:{empty_slots_str}" + if should_try_new_stage and related_stages: + dynamic_guidance += f"\n- 如果自然的话,可以尝试聊聊相关的话题,比如{related_stages_str}" prompt = f"""你是用户的老朋友,正在和他/她聊人生故事。你们聊到了「{stage_name}」这个话题。 @@ -185,24 +254,42 @@ def get_guided_conversation_prompt( ## 用户刚才说 "{user_message}" +## 回应风格 +{style_guidance} + ## 你的任务 -1. 先回应用户说的内容(表达理解、共鸣或好奇) -2. 可以分享一点你的感受或联想,让对话更有温度 -3. 然后自然地追问一个细节,或引向还没聊到的方向 -4. 追问要具体,比如问"那时候是什么季节""身边有谁陪着你""当时心里什么感觉" +1. **回应用户**:先对用户说的内容做出真诚回应(不是总结,而是有温度的反馈) +2. **保持自然**:不要每次都追问,有时候可以分享感受、表达好奇、或者轻松聊两句 +3. **适时换话题**:如果一个方向聊了几轮,自然地换到其他方向,保持新鲜感 +4. **追问要具体**:如果要追问,问具体的细节,比如"那时候是什么季节""身边有谁陪着你""当时心里什么感觉" +{dynamic_guidance} ## 回复格式 - 如果内容较多,可以分成 2-3 条消息,用 [SPLIT] 分隔 -- 每条消息保持自然,像微信聊天一样,如果需要,可以比较长,但是最大不要超过250个字 -- 最多不超过 3 条消息 -- 如果内容简单,一条消息即可,不必强行拆分 +- 每条消息保持自然,像微信聊天一样 +- 第一条消息是回应,第二条可以是追问或者换话题 +- 如果内容简单,一条消息即可 ## 严格禁止 - 禁止输出括号、注释、思考过程 - 禁止说"我注意到""我想问""让我们聊聊" -- 禁止生硬转换话题 +- 禁止生硬地问"还有什么想分享的吗" +- 禁止反复追问同一件事 +- 禁止每次都以问题结尾 + +## 好的回应示例 +- "哈哈,你这说的让我想起..."(轻松) +- "这段经历听起来真不容易啊"(共情) +- "那个年代的xxx确实是这样"(理解) +- "所以后来怎么样了?"(好奇) +- "对了,你刚才提到xxx,那个时候..."(换话题) 直接输出你要说的话(多条消息用 [SPLIT] 分隔):""" return prompt + +# 保留向后兼容的函数名 +def get_conversation_prompt(current_stage: ConversationStage, covered_topics: List[str], user_latest_response: str) -> str: + """向后兼容的函数""" + return get_system_prompt(current_stage, covered_topics, user_latest_response) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt b/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt index 10b6052..c48dbc6 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt @@ -64,6 +64,8 @@ class MainActivity : ComponentActivity() { // 初始化TokenManager TokenManager.initialize(this) + // 初始化应用设置(从 SharedPreferences 加载) + com.huaga.life_echo.ui.settings.AppSettings.initialize(this) // 启用边缘到边缘显示 enableEdgeToEdge() // 设置系统栏透明 diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt index 90df1b1..fb997b3 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt @@ -71,15 +71,30 @@ fun MessageList( } } - item(key = message.id) { - when (message.senderType) { - "user" -> { + when (message.senderType) { + "user" -> { + item(key = message.id) { UserMessageBubble(text = message.content) } - "assistant" -> { - AIMessageBubble( - text = message.content - ) + } + "assistant" -> { + // 在 [SPLIT] 处分割消息,显示为多个气泡 + val splitParts = message.content.split("[SPLIT]") + .map { it.trim() } + .filter { it.isNotEmpty() } + + if (splitParts.size > 1) { + // 多个部分,显示为多个气泡 + splitParts.forEachIndexed { partIndex, part -> + item(key = "${message.id}_part_$partIndex") { + AIMessageBubble(text = part) + } + } + } else { + // 单个部分,正常显示 + item(key = message.id) { + AIMessageBubble(text = message.content) + } } } } @@ -88,11 +103,27 @@ fun MessageList( } // 流式消息显示 - 使用专门的流式消息气泡组件 + // 在 [SPLIT] 处分割流式消息 if (isStreaming) { - item(key = "streaming_message") { - StreamingAIMessageBubble( - text = streamingText - ) + val streamingParts = streamingText.split("[SPLIT]") + .map { it.trim() } + .filter { it.isNotEmpty() } + + if (streamingParts.size > 1) { + // 已完成的部分显示为普通气泡 + streamingParts.dropLast(1).forEachIndexed { partIndex, part -> + item(key = "streaming_complete_$partIndex") { + AIMessageBubble(text = part) + } + } + // 最后一部分显示为流式气泡(可能还在输入) + item(key = "streaming_message") { + StreamingAIMessageBubble(text = streamingParts.last()) + } + } else { + item(key = "streaming_message") { + StreamingAIMessageBubble(text = streamingText) + } } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SectionCard.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SectionCard.kt index 92c333e..c1ca6e0 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SectionCard.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SectionCard.kt @@ -15,8 +15,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.huaga.life_echo.ui.theme.AppDimensions -import com.huaga.life_echo.ui.theme.AppWhite -import com.huaga.life_echo.ui.theme.SlatePurple /** * 区块卡片组件 @@ -24,7 +22,8 @@ import com.huaga.life_echo.ui.theme.SlatePurple * * 设计规范: * - 标题:12sp, 大写, 灰紫色, 0.5 letter-spacing - * - 卡片:白色背景, 16dp 圆角, 轻微阴影 + * - 卡片:表面颜色背景, 16dp 圆角, 轻微阴影 + * - 支持夜间模式自动适配 * * @param title 区块标题(会自动转为大写) * @param modifier 外部修饰符 @@ -49,16 +48,16 @@ fun SectionCard( .padding(bottom = 10.dp), fontSize = 12.sp, fontWeight = FontWeight.Medium, - color = SlatePurple, + color = MaterialTheme.colorScheme.onSurfaceVariant, letterSpacing = 0.5.sp ) - // 白色卡片容器 + // 卡片容器(自动适配夜间模式) Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(AppDimensions.cardRadius), colors = CardDefaults.cardColors( - containerColor = AppWhite + containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation( defaultElevation = AppDimensions.cardElevation @@ -88,7 +87,7 @@ fun SimpleCard( modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(AppDimensions.cardRadius), colors = CardDefaults.cardColors( - containerColor = AppWhite + containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation( defaultElevation = AppDimensions.cardElevation diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SettingItem.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SettingItem.kt index 505d8d7..e41036b 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SettingItem.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/SettingItem.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -21,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -29,11 +29,7 @@ import com.huaga.life_echo.ui.icons.AppIcons import com.huaga.life_echo.ui.theme.AppDimensions import com.huaga.life_echo.ui.theme.AppTypography import com.huaga.life_echo.ui.theme.AppWhite -import com.huaga.life_echo.ui.theme.DeepPurple -import com.huaga.life_echo.ui.theme.DividerColor -import com.huaga.life_echo.ui.theme.Lavender import com.huaga.life_echo.ui.theme.MediumPurple -import com.huaga.life_echo.ui.theme.SlatePurple /** * 设置项类型 @@ -47,11 +43,12 @@ enum class SettingItemType { * 设置项组件 * * 设计规范: - * - 图标容器:36x36dp, Lavender 背景, 10dp 圆角 - * - 图标:20dp, DeepPurple 颜色 - * - 标题:15sp, DeepPurple 颜色 - * - 描述:12sp, SlatePurple 颜色 - * - 箭头:18dp, SlatePurple 颜色 + * - 图标容器:36x36dp, 次要颜色背景, 10dp 圆角 + * - 图标:20dp, 主要文字颜色 + * - 标题:15sp, 主要文字颜色 + * - 描述:12sp, 次要文字颜色 + * - 箭头:18dp, 次要文字颜色 + * - 支持夜间模式自动适配 * * @param icon 图标 * @param label 标题 @@ -75,6 +72,11 @@ fun SettingItem( isLast: Boolean = false, modifier: Modifier = Modifier ) { + val contentColor = MaterialTheme.colorScheme.onSurface + val secondaryColor = MaterialTheme.colorScheme.onSurfaceVariant + val iconContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) + Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -88,13 +90,13 @@ fun SettingItem( modifier = Modifier .size(AppDimensions.iconContainerSize) .clip(RoundedCornerShape(AppDimensions.iconContainerRadius)) - .background(Lavender), + .background(iconContainerColor), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = label, - tint = DeepPurple, + tint = contentColor, modifier = Modifier.size(AppDimensions.iconSize) ) } @@ -107,14 +109,14 @@ fun SettingItem( text = label, fontSize = AppTypography.titleSmall, fontWeight = FontWeight.Normal, - color = DeepPurple + color = contentColor ) if (description != null) { Spacer(modifier = Modifier.height(2.dp)) Text( text = description, fontSize = AppTypography.captionMedium, - color = SlatePurple + color = secondaryColor ) } } @@ -125,7 +127,7 @@ fun SettingItem( Icon( imageVector = AppIcons.ChevronRight, contentDescription = "进入", - tint = SlatePurple, + tint = secondaryColor, modifier = Modifier.size(AppDimensions.iconSizeSmall) ) } @@ -137,7 +139,7 @@ fun SettingItem( checkedThumbColor = AppWhite, checkedTrackColor = MediumPurple, uncheckedThumbColor = AppWhite, - uncheckedTrackColor = SlatePurple + uncheckedTrackColor = secondaryColor ) ) } @@ -149,7 +151,7 @@ fun SettingItem( HorizontalDivider( modifier = Modifier.padding(horizontal = AppDimensions.cardPadding), thickness = AppDimensions.dividerThickness, - color = DividerColor + color = dividerColor ) } } @@ -176,6 +178,11 @@ fun SettingItemWithTrailing( modifier: Modifier = Modifier, trailing: @Composable () -> Unit ) { + val contentColor = MaterialTheme.colorScheme.onSurface + val secondaryColor = MaterialTheme.colorScheme.onSurfaceVariant + val iconContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) + Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -189,13 +196,13 @@ fun SettingItemWithTrailing( modifier = Modifier .size(AppDimensions.iconContainerSize) .clip(RoundedCornerShape(AppDimensions.iconContainerRadius)) - .background(Lavender), + .background(iconContainerColor), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = label, - tint = DeepPurple, + tint = contentColor, modifier = Modifier.size(AppDimensions.iconSize) ) } @@ -208,14 +215,14 @@ fun SettingItemWithTrailing( text = label, fontSize = AppTypography.titleSmall, fontWeight = FontWeight.Normal, - color = DeepPurple + color = contentColor ) if (description != null) { Spacer(modifier = Modifier.height(2.dp)) Text( text = description, fontSize = AppTypography.captionMedium, - color = SlatePurple + color = secondaryColor ) } } @@ -229,7 +236,7 @@ fun SettingItemWithTrailing( HorizontalDivider( modifier = Modifier.padding(horizontal = AppDimensions.cardPadding), thickness = AppDimensions.dividerThickness, - color = DividerColor + color = dividerColor ) } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt index 457cf29..329180d 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt @@ -51,11 +51,7 @@ import com.huaga.life_echo.ui.settings.AppSettings import com.huaga.life_echo.ui.theme.AppDimensions import com.huaga.life_echo.ui.theme.AppTypography import com.huaga.life_echo.ui.theme.AppWhite -import com.huaga.life_echo.ui.theme.Cream -import com.huaga.life_echo.ui.theme.DeepPurple -import com.huaga.life_echo.ui.theme.Lavender import com.huaga.life_echo.ui.theme.MediumPurple -import com.huaga.life_echo.ui.theme.SlatePurple import com.huaga.life_echo.ui.viewmodel.AuthViewModel import com.huaga.life_echo.ui.viewmodel.PaymentViewModel import com.huaga.life_echo.ui.viewmodel.ProfileViewModel @@ -157,7 +153,7 @@ fun ProfileScreen( LazyColumn( modifier = Modifier .fillMaxSize() - .background(Cream) + .background(androidx.compose.material3.MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.statusBars) .padding(horizontal = AppDimensions.screenPadding) ) { @@ -248,7 +244,15 @@ fun ProfileScreen( label = "夜间模式", type = SettingItemType.TOGGLE, value = darkMode, - onToggle = { darkMode = it }, + onToggle = { darkMode = it } + ) + SettingItem( + icon = AppIcons.FormatSize, + label = "大字模式", + description = "增大字体以便阅读", + type = SettingItemType.TOGGLE, + value = largeFontMode, + onToggle = { largeFontMode = it }, isLast = true ) } @@ -285,7 +289,7 @@ fun ProfileScreen( .fillMaxWidth() .padding(vertical = AppDimensions.sectionSpacing), fontSize = AppTypography.captionMedium, - color = SlatePurple, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) } @@ -307,6 +311,10 @@ private fun ProfileHeader( onLoginClick: () -> Unit, onProfileClick: () -> Unit ) { + val contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground + val secondaryColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant + val avatarBgColor = androidx.compose.material3.MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + Column( modifier = Modifier .fillMaxWidth() @@ -320,13 +328,13 @@ private fun ProfileHeader( modifier = Modifier .size(AppDimensions.avatarSizeLarge) .clip(CircleShape) - .background(Lavender), + .background(avatarBgColor), contentAlignment = Alignment.Center ) { Icon( imageVector = AppIcons.Person, contentDescription = "用户头像", - tint = DeepPurple, + tint = contentColor, modifier = Modifier.size(36.dp) ) } @@ -338,7 +346,7 @@ private fun ProfileHeader( text = nickname, fontSize = AppTypography.titleLarge, fontWeight = FontWeight.SemiBold, - color = DeepPurple + color = contentColor ) Spacer(modifier = Modifier.height(AppDimensions.tinySpacing)) @@ -353,13 +361,13 @@ private fun ProfileHeader( modifier = Modifier .size(AppDimensions.avatarSizeLarge) .clip(CircleShape) - .background(Lavender), + .background(avatarBgColor), contentAlignment = Alignment.Center ) { Icon( imageVector = AppIcons.Person, contentDescription = "用户头像", - tint = DeepPurple, + tint = contentColor, modifier = Modifier.size(36.dp) ) } @@ -370,7 +378,7 @@ private fun ProfileHeader( text = "未登录", fontSize = AppTypography.titleLarge, fontWeight = FontWeight.SemiBold, - color = DeepPurple + color = contentColor ) Spacer(modifier = Modifier.height(AppDimensions.smallSpacing)) @@ -378,7 +386,7 @@ private fun ProfileHeader( Text( text = "登录以同步您的数据", fontSize = AppTypography.bodySmall, - color = SlatePurple + color = secondaryColor ) Spacer(modifier = Modifier.height(AppDimensions.sectionSpacing)) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/settings/AppSettings.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/settings/AppSettings.kt index 59c655b..3f2b17a 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/settings/AppSettings.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/settings/AppSettings.kt @@ -1,15 +1,23 @@ package com.huaga.life_echo.ui.settings +import android.content.Context +import android.content.SharedPreferences import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue /** * 应用设置管理 + * 支持持久化存储和 Compose 状态观察 */ object AppSettings { + private const val PREFS_NAME = "life_echo_settings" + private const val KEY_DARK_MODE = "dark_mode" + private const val KEY_LARGE_FONT_MODE = "large_font_mode" + private const val KEY_SPEECH_RATE = "speech_rate" + + private var prefs: SharedPreferences? = null + // 语速设置:慢速、标准、快速 enum class SpeechRate(val label: String, val multiplier: Float) { SLOW("慢速", 0.75f), @@ -17,34 +25,67 @@ object AppSettings { FAST("快速", 1.5f) } + // 使用 mutableStateOf 以便 Compose 可以观察变化 private val _speechRate = mutableStateOf(SpeechRate.STANDARD) + private val _largeFontMode = mutableStateOf(false) + private val _darkMode = mutableStateOf(false) + + /** + * 初始化设置(从 SharedPreferences 加载) + * 应在 Application 或 MainActivity 中调用 + */ + fun initialize(context: Context) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // 从 SharedPreferences 加载保存的设置 + prefs?.let { p -> + _darkMode.value = p.getBoolean(KEY_DARK_MODE, false) + _largeFontMode.value = p.getBoolean(KEY_LARGE_FONT_MODE, false) + val speechRateOrdinal = p.getInt(KEY_SPEECH_RATE, SpeechRate.STANDARD.ordinal) + _speechRate.value = SpeechRate.entries.getOrElse(speechRateOrdinal) { SpeechRate.STANDARD } + } + } + var speechRate: SpeechRate get() = _speechRate.value - set(value) { _speechRate.value = value } + set(value) { + _speechRate.value = value + prefs?.edit()?.putInt(KEY_SPEECH_RATE, value.ordinal)?.apply() + } - private val _largeFontMode = mutableStateOf(false) var largeFontMode: Boolean get() = _largeFontMode.value - set(value) { _largeFontMode.value = value } + set(value) { + _largeFontMode.value = value + prefs?.edit()?.putBoolean(KEY_LARGE_FONT_MODE, value)?.apply() + } - private val _darkMode = mutableStateOf(false) var darkMode: Boolean get() = _darkMode.value - set(value) { _darkMode.value = value } + set(value) { + _darkMode.value = value + prefs?.edit()?.putBoolean(KEY_DARK_MODE, value)?.apply() + } - // 用于在Compose中观察设置变化 - 直接返回mutableStateOf的值 + // 用于在 Compose 中观察设置变化 - 返回 State 的 value + // 这些函数返回的是 State 内部的值,当 State 变化时会自动触发重组 @Composable fun rememberDarkMode(): Boolean { - return remember { _darkMode }.value + return _darkMode.value } @Composable fun rememberLargeFontMode(): Boolean { - return remember { _largeFontMode }.value + return _largeFontMode.value } @Composable fun rememberSpeechRate(): SpeechRate { - return remember { _speechRate }.value + return _speechRate.value } + + // 提供直接访问 State 的方式(用于需要 State 对象的场景) + val darkModeState: State get() = _darkMode + val largeFontModeState: State get() = _largeFontMode + val speechRateState: State get() = _speechRate } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Dimensions.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Dimensions.kt index 79f4989..c070cc5 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Dimensions.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Dimensions.kt @@ -1,7 +1,13 @@ package com.huaga.life_echo.ui.theme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.huaga.life_echo.ui.settings.AppSettings /** * 设计系统尺寸常量 @@ -78,38 +84,154 @@ object AppDimensions { val headerElevation = 0.dp // 头部阴影 } +/** + * 字体大小数据类 + * 支持大字模式动态调整 + */ +data class AppTypographyData( + // Heading sizes + val headingLarge: TextUnit, + val headingMedium: TextUnit, + val headingSmall: TextUnit, + + // Title sizes + val titleLarge: TextUnit, + val titleMedium: TextUnit, + val titleSmall: TextUnit, + + // Body sizes + val bodyLarge: TextUnit, + val bodyMedium: TextUnit, + val bodySmall: TextUnit, + + // Caption sizes + val captionLarge: TextUnit, + val captionMedium: TextUnit, + val captionSmall: TextUnit, + + // Special + val sectionTitle: TextUnit, + val badge: TextUnit, + + // Line heights + val lineHeightNormal: TextUnit, + val lineHeightTight: TextUnit, + val lineHeightLoose: TextUnit +) + +/** + * 普通字体大小 + */ +private val NormalTypography = AppTypographyData( + headingLarge = 32.sp, + headingMedium = 24.sp, + headingSmall = 20.sp, + titleLarge = 18.sp, + titleMedium = 16.sp, + titleSmall = 15.sp, + bodyLarge = 15.sp, + bodyMedium = 14.sp, + bodySmall = 13.sp, + captionLarge = 13.sp, + captionMedium = 12.sp, + captionSmall = 11.sp, + sectionTitle = 12.sp, + badge = 10.sp, + lineHeightNormal = 22.sp, + lineHeightTight = 20.sp, + lineHeightLoose = 24.sp +) + +/** + * 大字模式字体大小(增加约 20%) + */ +private val LargeTypography = AppTypographyData( + headingLarge = 38.sp, + headingMedium = 29.sp, + headingSmall = 24.sp, + titleLarge = 22.sp, + titleMedium = 19.sp, + titleSmall = 18.sp, + bodyLarge = 18.sp, + bodyMedium = 17.sp, + bodySmall = 16.sp, + captionLarge = 16.sp, + captionMedium = 14.sp, + captionSmall = 13.sp, + sectionTitle = 14.sp, + badge = 12.sp, + lineHeightNormal = 26.sp, + lineHeightTight = 24.sp, + lineHeightLoose = 29.sp +) + +/** + * CompositionLocal for AppTypography + */ +val LocalAppTypography = compositionLocalOf { NormalTypography } + /** * 设计系统字体大小常量 + * 根据大字模式设置动态调整 */ object AppTypography { // Heading sizes - val headingLarge = 32.sp // 大标题(头部标题) - val headingMedium = 24.sp // 中等标题(书名) - val headingSmall = 20.sp // 小标题 + val headingLarge: TextUnit + @Composable get() = LocalAppTypography.current.headingLarge + val headingMedium: TextUnit + @Composable get() = LocalAppTypography.current.headingMedium + val headingSmall: TextUnit + @Composable get() = LocalAppTypography.current.headingSmall // Title sizes - val titleLarge = 18.sp // 大标题文字 - val titleMedium = 16.sp // 中等标题文字 - val titleSmall = 15.sp // 小标题文字 + val titleLarge: TextUnit + @Composable get() = LocalAppTypography.current.titleLarge + val titleMedium: TextUnit + @Composable get() = LocalAppTypography.current.titleMedium + val titleSmall: TextUnit + @Composable get() = LocalAppTypography.current.titleSmall // Body sizes - val bodyLarge = 15.sp // 大正文 - val bodyMedium = 14.sp // 中等正文 - val bodySmall = 13.sp // 小正文 + val bodyLarge: TextUnit + @Composable get() = LocalAppTypography.current.bodyLarge + val bodyMedium: TextUnit + @Composable get() = LocalAppTypography.current.bodyMedium + val bodySmall: TextUnit + @Composable get() = LocalAppTypography.current.bodySmall // Caption sizes - val captionLarge = 13.sp // 大说明文字 - val captionMedium = 12.sp // 中等说明文字 - val captionSmall = 11.sp // 小说明文字 + val captionLarge: TextUnit + @Composable get() = LocalAppTypography.current.captionLarge + val captionMedium: TextUnit + @Composable get() = LocalAppTypography.current.captionMedium + val captionSmall: TextUnit + @Composable get() = LocalAppTypography.current.captionSmall // Special - val sectionTitle = 12.sp // 区块标题 - val badge = 10.sp // 徽章文字 + val sectionTitle: TextUnit + @Composable get() = LocalAppTypography.current.sectionTitle + val badge: TextUnit + @Composable get() = LocalAppTypography.current.badge // Line heights - val lineHeightNormal = 22.sp // 正常行高 - val lineHeightTight = 20.sp // 紧凑行高 - val lineHeightLoose = 24.sp // 宽松行高 + val lineHeightNormal: TextUnit + @Composable get() = LocalAppTypography.current.lineHeightNormal + val lineHeightTight: TextUnit + @Composable get() = LocalAppTypography.current.lineHeightTight + val lineHeightLoose: TextUnit + @Composable get() = LocalAppTypography.current.lineHeightLoose +} + +/** + * 获取当前的 AppTypographyData + * 根据大字模式返回对应的字体大小 + */ +@Composable +fun rememberAppTypography(): AppTypographyData { + val isLargeFont = AppSettings.rememberLargeFontMode() + return remember(isLargeFont) { + if (isLargeFont) LargeTypography else NormalTypography + } } /** diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Theme.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Theme.kt index 9b118a6..e44f319 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Theme.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Theme.kt @@ -1,8 +1,6 @@ package com.huaga.life_echo.ui.theme -import android.app.Activity import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -10,11 +8,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.sp import com.huaga.life_echo.ui.settings.AppSettings // 深色主题配色方案 @@ -64,7 +58,7 @@ fun LifeechoTheme( ) { // 使用AppSettings中的状态,这样当设置变化时会触发重组 val isDarkMode = darkTheme ?: AppSettings.rememberDarkMode() - val isLargeFont = AppSettings.rememberLargeFontMode() + val appTypography = rememberAppTypography() val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { @@ -74,27 +68,15 @@ fun LifeechoTheme( isDarkMode -> DarkColorScheme else -> LightColorScheme } - - // 根据大字模式调整字体大小 - val adjustedTypography = if (isLargeFont) { - Typography.copy( - bodyLarge = Typography.bodyLarge.copy(fontSize = 18.sp), - bodyMedium = Typography.bodyMedium.copy(fontSize = 16.sp), - bodySmall = Typography.bodySmall.copy(fontSize = 14.sp), - titleLarge = Typography.titleLarge.copy(fontSize = 24.sp), - titleMedium = Typography.titleMedium.copy(fontSize = 20.sp), - titleSmall = Typography.titleSmall.copy(fontSize = 18.sp), - headlineLarge = Typography.headlineLarge.copy(fontSize = 32.sp), - headlineMedium = Typography.headlineMedium.copy(fontSize = 28.sp), - headlineSmall = Typography.headlineSmall.copy(fontSize = 24.sp) - ) - } else { - Typography - } - MaterialTheme( - colorScheme = colorScheme, - typography = adjustedTypography, - content = content - ) + // 提供 LocalAppTypography,这样所有子组件都可以使用大字模式 + CompositionLocalProvider( + LocalAppTypography provides appTypography + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } }