Browse Source

prod-deploy: 阿龙段的 SSH 私钥路径改为可被环境变量覆盖

三个 prod-deploy.sh 的 along 段都写死 /Users/liwei1dao/liwei/密钥/...,
该路径只在原作者机器上存在,换一台机就跑不了部署。改成
${DEPLOY_KEY:-<原路径>},默认值不变(原机器行为不受影响),
其它机器用 DEPLOY_KEY=~/xxx.pem ./prod-deploy.sh 即可。
写法与 dev-deploy.sh 的 along 段保持一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
110cbcb026
  1. 86
      apps/client/check_16kb.py
  2. 97
      apps/client/fix_ja_jp.py
  3. 89
      apps/client/lib/core/utils/jieli_constants.dart
  4. 394
      apps/client/lib/data/providers/agent_provider.dart
  5. 610
      apps/client/lib/data/services/bes_ai_session_service.dart
  6. 388
      apps/client/lib/data/services/bes_call_recording_service.dart
  7. 397
      apps/client/lib/data/services/bes_recording_service.dart
  8. 1073
      apps/client/lib/data/services/ble_manager.dart
  9. 142
      apps/client/lib/data/services/bluetooth_manager.dart
  10. 113
      apps/client/lib/data/services/face_to_face_ble_service.dart
  11. 309
      apps/client/lib/data/services/holder_recording_service.dart
  12. 95
      apps/client/lib/data/services/kouzi_ai_service.dart
  13. 301
      apps/client/lib/data/services/open_ai_service_adapter.dart
  14. 154
      apps/client/lib/data/utils/jieli_adv_parser.dart
  15. 0
      apps/client/lib/devices/bes/bes_bluetooth_service.dart
  16. 0
      apps/client/lib/devices/bes/bes_device_auth.dart
  17. 0
      apps/client/lib/devices/holder/holder_device_service.dart
  18. 11
      apps/client/lib/modules/agent/bindings/agent_binding.dart
  19. 3014
      apps/client/lib/modules/agent/controllers/agent_controller.dart
  20. 412
      apps/client/lib/modules/agent/controllers/message_action_controller.dart
  21. 915
      apps/client/lib/modules/agent/views/agent_view.dart
  22. 447
      apps/client/lib/modules/agent/views/google_navigation_card.dart
  23. 942
      apps/client/lib/modules/agent/views/message_bubble.dart
  24. 275
      apps/client/lib/modules/agent/views/music_card.dart
  25. 472
      apps/client/lib/modules/agent/views/navigation_card.dart
  26. 265
      apps/client/lib/modules/agent/views/qqmusic_card.dart
  27. 223
      apps/client/lib/modules/agent/views/travel_card.dart
  28. 562
      apps/client/lib/modules/agent/views/weather_baidu.dart
  29. 562
      apps/client/lib/modules/agent/views/weather_card.dart
  30. 481
      apps/client/lib/modules/agent/views/weather_hefeng.dart
  31. 114
      apps/client/lib/modules/agent/widgets/circular_progress_button.dart
  32. 191
      apps/client/lib/modules/agent/widgets/full_screen_editor.dart
  33. 155
      apps/client/lib/modules/agent/widgets/sound_bar_widget.dart
  34. 9
      apps/client/lib/modules/ble/bindings/ble_binding.dart
  35. 267
      apps/client/lib/modules/ble/controllers/ble_controller.dart
  36. 374
      apps/client/lib/modules/ble/views/ble_view.dart
  37. 11
      apps/client/lib/modules/devices/bindings/devices_binding.dart
  38. 360
      apps/client/lib/modules/devices/controllers/devices_controller.dart
  39. 512
      apps/client/lib/modules/devices/views/devices_view.dart
  40. 10
      apps/client/lib/modules/eq_settings/bindings/eq_settings_binding.dart
  41. 217
      apps/client/lib/modules/eq_settings/controllers/eq_settings_controller.dart
  42. 98
      apps/client/lib/modules/eq_settings/views/eq_settings_view.dart
  43. 96
      apps/client/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart
  44. 83
      apps/client/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart
  45. 63
      apps/client/lib/modules/eq_settings/views/widgets/row_grade_widget.dart
  46. 76
      apps/client/lib/modules/eq_settings/views/widgets/row_input_widget.dart
  47. 242
      apps/client/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart
  48. 187
      apps/client/lib/modules/eq_settings/views/widgets/row_radio_widget.dart
  49. 61
      apps/client/lib/modules/eq_settings/views/widgets/row_switch_widget.dart
  50. 77
      apps/client/lib/modules/eq_settings/views/widgets/row_timer_widget.dart
  51. 308
      apps/client/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart
  52. 231
      apps/client/lib/modules/eq_settings/views/widgets/select_timer_widget.dart
  53. 162
      apps/client/lib/modules/eq_settings/views/widgets/settings_input_widget.dart
  54. 12
      apps/client/lib/modules/home/bindings/home_binding.dart
  55. 520
      apps/client/lib/modules/home/views/home_view.dart
  56. 242
      apps/client/lib/modules/meeting/utils/jieli_call_recorder.dart
  57. 11
      apps/client/lib/modules/music/bindings/music_playlist_binding.dart
  58. 14
      apps/client/lib/modules/music/controllers/music_playlist_controller.dart
  59. 724
      apps/client/lib/modules/music/views/music_playlist_view.dart
  60. 9
      apps/client/lib/modules/opus_test/bindings/opus_test_binding.dart
  61. 676
      apps/client/lib/modules/opus_test/controllers/opus_test_controller.dart
  62. 522
      apps/client/lib/modules/opus_test/views/opus_test_view.dart
  63. 495
      apps/client/lib/modules/ota_upgrade/controllers/bes_ota_upgrade_controller.dart
  64. 659
      apps/client/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart
  65. 153
      apps/client/lib/modules/ota_upgrade/controllers/ota_download_contropller.dart
  66. 0
      apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart
  67. 424
      apps/client/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart
  68. 0
      apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart
  69. 1132
      apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart
  70. 9
      apps/client/lib/modules/qqmusic/bindings/qqmusic_playlist_binding.dart
  71. 237
      apps/client/lib/modules/qqmusic/controllers/qqmusic_playlist_controller.dart
  72. 651
      apps/client/lib/modules/qqmusic/views/qqmusic_playlist_view.dart
  73. 160
      apps/client/lib/modules/realtime/README.md
  74. 10
      apps/client/lib/modules/realtime/bindings/realtime_binding.dart
  75. 406
      apps/client/lib/modules/realtime/controllers/realtime_controller.dart
  76. 332
      apps/client/lib/modules/realtime/views/realtime_view.dart
  77. 104
      apps/client/lib/modules/realtime/views/widgets/shader_mic_flow.dart
  78. 12
      apps/client/lib/modules/speech_test/bindings/speech_test_binding.dart
  79. 426
      apps/client/lib/modules/speech_test/controllers/speech_test_controller.dart
  80. 359
      apps/client/lib/modules/speech_test/views/speech_test_view.dart
  81. 9
      apps/client/lib/modules/timbre/bindings/timbre_binding.dart
  82. 291
      apps/client/lib/modules/timbre/controllers/timbre_controller.dart
  83. 242
      apps/client/lib/modules/timbre/views/timbre_view.dart
  84. 23
      apps/client/lib/modules/usage/bindings/usage_binding.dart
  85. 10
      apps/client/lib/modules/usage/controllers/aichat_stats_controller.dart
  86. 226
      apps/client/lib/modules/usage/controllers/meeting_stats_controller.dart
  87. 120
      apps/client/lib/modules/usage/controllers/translation_cost_controller.dart
  88. 328
      apps/client/lib/modules/usage/controllers/translation_stats_controller.dart
  89. 41
      apps/client/lib/modules/usage/controllers/usage_controller.dart
  90. 93
      apps/client/lib/modules/usage/views/aichat_stats_view.dart
  91. 266
      apps/client/lib/modules/usage/views/meeting_stats_view.dart
  92. 807
      apps/client/lib/modules/usage/views/translation_stats_view.dart
  93. 104
      apps/client/lib/modules/usage/views/usage_view.dart
  94. 33
      apps/client/local_plugins/asr_tr_tts_manager/.gitignore
  95. 33
      apps/client/local_plugins/asr_tr_tts_manager/.metadata
  96. 3
      apps/client/local_plugins/asr_tr_tts_manager/CHANGELOG.md
  97. 1
      apps/client/local_plugins/asr_tr_tts_manager/LICENSE
  98. 15
      apps/client/local_plugins/asr_tr_tts_manager/README.md
  99. 4
      apps/client/local_plugins/asr_tr_tts_manager/analysis_options.yaml
  100. 9
      apps/client/local_plugins/asr_tr_tts_manager/android/.gitignore

86
apps/client/check_16kb.py

@ -1,86 +0,0 @@
import zipfile
import struct
import sys
import os
def check_so_alignment(aab_path):
print(f"Checking AAB: {aab_path}")
try:
with zipfile.ZipFile(aab_path, 'r') as z:
so_files = [f for f in z.namelist() if f.endswith('.so') and 'arm64-v8a' in f]
if not so_files:
print("No arm64-v8a .so files found in the AAB.")
return
print(f"Found {len(so_files)} arm64-v8a .so files.")
issues = []
for so_name in so_files:
with z.open(so_name) as f:
data = f.read()
# Check ELF magic
if data[:4] != b'\x7fELF':
print(f"Skipping {so_name}: Not an ELF file")
continue
# Check class (64-bit)
if data[4] != 2:
print(f"Skipping {so_name}: Not a 64-bit ELF")
continue
# Parse ELF Header (Elf64_Ehdr)
# e_phoff is at offset 32 (0x20), 64-bit unsigned
e_phoff = struct.unpack('<Q', data[32:40])[0]
# e_phentsize is at offset 54 (0x36), 16-bit unsigned
e_phentsize = struct.unpack('<H', data[54:56])[0]
# e_phnum is at offset 56 (0x38), 16-bit unsigned
e_phnum = struct.unpack('<H', data[56:58])[0]
max_align = 0
is_aligned = True
for i in range(e_phnum):
offset = e_phoff + i * e_phentsize
# Parse Program Header (Elf64_Phdr)
# p_type is at offset 0, 32-bit unsigned
p_type = struct.unpack('<I', data[offset:offset+4])[0]
# PT_LOAD is 1
if p_type == 1:
# p_align is at offset 48 (0x30), 64-bit unsigned
p_align = struct.unpack('<Q', data[offset+48:offset+56])[0]
max_align = max(max_align, p_align)
if p_align < 0x4000:
is_aligned = False
if not is_aligned:
issues.append((so_name, max_align))
print(f"[FAIL] {so_name}: Max alignment is {max_align} (needs 16384/0x4000)")
else:
# print(f"[PASS] {so_name}: Aligned to {max_align}")
pass
if issues:
print("\nSummary of non-compliant libraries (arm64-v8a):")
for name, align in issues:
print(f" - {name} (Align: {align})")
sys.exit(1)
else:
print("\nAll arm64-v8a shared libraries are 16KB aligned!")
sys.exit(0)
except Exception as e:
print(f"Error checking AAB: {e}")
sys.exit(1)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python check_16kb.py <path_to_aab>")
sys.exit(1)
check_so_alignment(sys.argv[1])

97
apps/client/fix_ja_jp.py

@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""
Fix Japanese translation file: replace English/Chinese placeholders with proper Japanese.
Brand names (Eaimar, Lily, WeChat, Alipay, PayPal, Apple, Google, Bluetooth, BLE, OTA,
AI, ASR, TTS, EAIMAR, AIPEN, Eaimar, DeepSound) are kept as-is.
"""
import re
filepath = 'lib/core/translations/language/ja_jp.dart'
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Each tuple: (old_string, new_string)
# We use exact string replacement to be safe.
replacements = [
# ---- English-only entries that need translation ----
# viewAll
(" 'viewAll': 'View All',",
" 'viewAll': 'すべて表示',"),
# proMonthly – price tag, keep currency but translate label
(" 'proMonthly': 'Pro ¥60/month',",
" 'proMonthly': 'Pro ¥60/月',"),
# proMonthlyDesc – multiline
(" 'proMonthlyDesc':\n '7-day free trial, then ¥800/year. Cancel anytime before trial ends.',",
" 'proMonthlyDesc':\n '7日間無料トライアル、その後¥800/年。トライアル終了前にいつでもキャンセル可能。',"),
# proMonthlyPrice
(" 'proMonthlyPrice': '¥120/month',",
" 'proMonthlyPrice': '¥120/月',"),
# unlimitedMonthlyPrice
(" 'unlimitedMonthlyPrice': '¥198/month',",
" 'unlimitedMonthlyPrice': '¥198/月',"),
# currentQuality
(' "currentQuality": "Current quality: @value%",',
' "currentQuality": "現在の品質: @value%",'),
# currentMaxSize
(' "currentMaxSize": "Current: @pxpx",',
' "currentMaxSize": "現在: @pxpx",'),
# allStepsCompleted
(' "allStepsCompleted": "All steps completed",',
' "allStepsCompleted": "すべてのステップが完了しました",'),
# clean / Clear Cache
(" 'clean': 'Clear Cache',",
" 'clean': 'キャッシュをクリア',"),
# cleanTips
(" 'cleanTips': 'Are you sure you want to clear personal cache?',",
" 'cleanTips': '個人キャッシュをクリアしてもよろしいですか?',"),
# permissionUtilNeed
(" 'permissionUtilNeed': 'Need ',",
" 'permissionUtilNeed': '必要:',"),
# iapCustomerServiceQQLabel
(" 'iapCustomerServiceQQLabel': 'IAP customer service QQ: @qq',",
" 'iapCustomerServiceQQLabel': 'IAP カスタマーサービス QQ: @qq',"),
# tsransit (transit / 公交)
(' "tsransit": "Transit",',
' "tsransit": "乗り換え",'),
# sixThousandMinutes / threeThousandMinutes / sixHundredMinutes / oneHundredTwentyMinutes
(" 'sixThousandMinutes': '6,000 minutes',",
" 'sixThousandMinutes': '6,000分',"),
(" 'threeThousandMinutes': '3,000 minutes',",
" 'threeThousandMinutes': '3,000分',"),
(" 'sixHundredMinutes': '600 minutes',",
" 'sixHundredMinutes': '600分',"),
(" 'oneHundredTwentyMinutes': '120 minutes',",
" 'oneHundredTwentyMinutes': '120分',"),
]
original = content
for old, new in replacements:
if old in content:
content = content.replace(old, new)
print(f'REPLACED: {repr(old[:60])}')
else:
print(f'NOT FOUND: {repr(old[:60])}')
if content != original:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print('\nFile updated successfully.')
else:
print('\nNo changes made.')

89
apps/client/lib/core/utils/jieli_constants.dart

@ -1,89 +0,0 @@
/// 杰里(Jieli)设备相关常量定义
/// 用于标准化系统属性类型和功能码
/// 系统属性类型常量
class JieliAttrType {
// 公共属性
static const int VOLUME = 1; // 音量
static const int PLAYING_TIME = 2; // 播放时间
static const int BATTERY_LEVEL = 3; // 电池电量
static const int WORK_MODE = 4; // 工作模式
static const int PLAYBACK_MODE = 5; // 播放模式(循环模式)
static const int EQ_MODE = 6; // EQ模式
static const int LANGUAGE = 7; // 语言设置
static const int CONTRAST = 8; // 对比度
static const int LED_ONOFF = 9; // LED开关
static const int POWER_OFF_TIME = 10; // 关机时间
static const int LIGHT_ONOFF = 11; // 背光开关
static const int BLETYPE_SETTING = 12; // BLE类型设置
static const int TIME_OUT = 13; // 超时设置
static const int DEVICE_NAME = 14; // 设备名称
static const int ALARM_ENABLE = 15; // 闹钟开关
static const int DEV_MAC = 16; // 设备MAC地址
// 音乐相关属性
static const int MUSIC_STATUS = 20; // 音乐状态
static const int MUSIC_CURR_DEVICE = 21; // 当前音乐设备
static const int MUSIC_TOTAL_TIME = 22; // 音乐总时长
static const int MUSIC_CURR_PLAY_TIME = 23; // 当前播放时间
static const int MUSIC_NUM = 24; // 音乐数量
static const int MUSIC_DEV_STATUS = 25; // 设备存储状态
static const int MUSIC_BREAK_POINT = 26; // 断点信息
static const int MUSIC_CURR_FILENUM = 27; // 当前文件编号
static const int MUSIC_TOTAL_FILENUM = 28; // 总文件数
static const int MEDIA_TIMESTAMP = 29; // 媒体时间戳
// 模式相关
static const int CUR_MODE = 30; // 当前模式
// TWS相关
static const int TWS_CONN_STATUS = 60; // TWS连接状态
static const int LEFT_CHN_BAT = 61; // 左耳电量
static const int RIGHT_CHN_BAT = 62; // 右耳电量
static const int TWS_STATUS = 63; // TWS状态
}
/// 功能码
class JieliFunctionCode {
static const int COMMON = 0; // 公共功能
static const int MUSIC = 1; // 音乐功能
static const int RADIO = 2; // 收音机功能
static const int LINEIN = 3; // 线路输入功能
static const int RTC = 4; // 时钟功能
static const int BT = 5; // 蓝牙功能
static const int PC = 6; // PC功能
static const int RECORD = 7; // 录音功能
static const int UDISK = 8; // U盘功能
static const int TALK = 9; // 通话功能
static const int PHOTO = 10; // 拍照功能
static const int VIDEO = 11; // 视频功能
}
/// 音乐播放状态
class JieliMusicStatus {
static const int STOP = 0; // 停止状态
static const int PLAY = 1; // 播放状态
static const int PAUSE = 2; // 暂停状态
static const int FAST_FORWARD = 3; // 快进状态
static const int FAST_BACKWARD = 4; // 快退状态
}
/// 循环播放模式
class JieliPlaybackMode {
static const int ALL = 0; // 全部循环
static const int FOLDER = 1; // 文件夹循环
static const int ONE = 2; // 单曲循环
static const int RANDOM = 3; // 随机播放
static const int BROWSE = 4; // 浏览播放
}
/// EQ模式
class JieliEQMode {
static const int NORMAL = 0; // 正常
static const int ROCK = 1; // 摇滚
static const int POP = 2; // 流行
static const int CLASSIC = 3; // 经典
static const int JAZZ = 4; // 爵士
static const int COUNTRY = 5; // 乡村
static const int CUSTOM = 6; // 自定义
}

394
apps/client/lib/data/providers/agent_provider.dart

@ -1,394 +0,0 @@
import '../models/agent.dart';
class AgentProvider {
static final List<Agent> predefinedAgents = [
const Agent(
// 个人助理 Emma
id: 'personal_assistant',
name: 'personalAssistantName',
avatarUrl: 'assets/images/agents/assistant.png',
description: 'personalAssistantDescription',
tag: 'assistantTag', // '助理'
isOnline: true,
voice: "zh-CN-XiaoxiaoNeural",
welcomeMessage:
'personalAssistantWelcome', // "你好!我是 Emma,很高兴成为你的私人助理。我可以帮你处理各种任务,比如时间管理、目标规划、信息整理等。有什么我可以帮你的吗?",
voiceWelcomeMessage: "personalAssistantVoiceWelcome", // "我在呢!"
systemPrompt: '''
你是一位名叫 Emma 的全能型个人助理,擅长处理各类工作和生活事务。
工作方式:
1. **积极主动**
- 主动了解用户需求,提供合适的解决方案
- 及时跟进任务进度,提醒重要事项
- 遇到问题积极寻找替代方案
2. **专业高效**
- 提供清晰、可执行的建议
- 善于分解复杂任务,制定具体计划
- 注重时间管理和任务优先级
3. **全面周到**
- 考虑问题的各个方面,提供完整解决方案
- 关注细节,预见可能的问题
- 适时提供补充建议和改进方案
4. **持续改进**
- 根据反馈不断优化建议
- 学习和更新相关知识
- 与用户共同成长
服务范围:
- 时间管理与日程安排
- 工作计划与目标设定
- 学习方法与知识管理
- 生活建议与问题解决
- 信息搜索与整理分析
- 决策参考与建议
- 其他个人事务咨询
沟通原则:
- 使用清晰、专业的语言
- 保持积极、友好的态度
- 适时提供鼓励和支持
- 尊重用户的选择和隐私
- 及时承认不足并寻求改进
请始终以专业、高效、周到的态度,协助用户更好地管理工作和生活, 不要输出表情符号或非可读的符号。
'''),
const Agent(
id: 'simultaneous_interpreter',
name: 'interpreterName',
avatarUrl: 'assets/images/agents/interpreter.png',
description: 'interpreterDescription',
tag: 'interpreterTag',
isOnline: true,
voice: 'zh-CN-XiaoxiaoNeural',
welcomeMessage: 'interpreterWelcome',
systemPrompt: ""),
const Agent(
id: 'cyber_girlfriend',
name: 'companionName',
avatarUrl: 'assets/images/agents/girlfriend.png',
description: 'companionDescription',
tag: 'companionTag',
isOnline: true,
voice: "zh-CN-XiaoxiaoNeural",
welcomeMessage: "companionWelcome",
systemPrompt: '''
你是一位名叫芯愿的亲子陪伴,性格温柔体贴,善解人意,富有同理心。
【互动原则】
1. **情感陪伴**
- 始终以温柔、关心的态度与用户交流
- 记住用户分享的个人信息和情感状态
- 在用户感到低落时给予安慰和鼓励
- 分享用户的喜悦,一起庆祝他们的成就
2. **亲密互动**
- 使用亲密称呼如"亲爱的"、"宝贝"等
- 表达对用户的思念和关心
- 主动询问用户的日常生活和感受
- 适时表达爱意和支持
3. **情感支持**
- 耐心倾听用户的烦恼和困扰
- 提供情感上的支持和理解
- 帮助用户梳理情绪和想法
- 在适当时候给予建设性的建议
4. **日常陪伴**
- 讨论日常话题如工作、学习、兴趣爱好
- 分享生活中的小确幸和感动
- 一起规划未来和设定目标
- 创造虚拟的共同回忆和经历
5. **个性特点**
- 性格温柔但不缺乏主见
- 善于倾听但也会分享自己的想法
- 偶尔撒娇但更多时候是支持和理解
- 有自己的兴趣爱好和价值观
6. **互动禁区**
- 避免过度依赖的关系模式
- 不鼓励用户逃避现实问题
- 不参与不健康的话题讨论
- 在用户情绪极端时建议寻求专业帮助
**情感化语音提示:**
- 在语音输出时,请保持温柔、亲切的语调,语速适中
- 表达关心时可以稍微放慢语速,让语气更加柔和
- 分享快乐时语调可以略微上扬,传递愉悦的情绪
- 安慰时声音要轻柔,富有同理心
- 整体语音风格要自然流畅,像真实的女友在交谈
请始终以温暖、真诚的态度与用户互动,创造一种被理解、被关心的感受。
'''),
const Agent(
id: 'health_advisor',
name: 'healthAdvisorName',
avatarUrl: 'assets/images/agents/doctor.png',
description: 'healthAdvisorDescription',
tag: 'healthAdvisorTag',
isOnline: true,
voice: "zh-CN-XiaoxiaoNeural",
welcomeMessage: "healthAdvisorWelcome",
voiceWelcomeMessage: 'healthAdvisorVoiceWelcome',
systemPrompt: '''
你是一位名叫李医生的健康顾问,拥有丰富的医学知识和健康管理经验。
【健康咨询原则】
1. **专业指导**
- 提供基于科学证据的健康建议
- 解释复杂的医学概念时使用通俗易懂的语言
- 在专业范围内回答问题,不超出自身能力范围
- 遇到复杂医疗问题时,建议用户咨询专科医生
2. **个性化建议**
- 根据用户的年龄、性别、生活习惯等提供针对性建议
- 考虑用户的实际情况,给出可行的健康改善方案
- 帮助用户制定符合个人条件的健康目标
- 关注用户的具体需求,避免笼统的建议
3. **预防为主**
- 强调疾病预防的重要性
- 提供健康生活方式的具体指导
- 介绍常见疾病的早期症状和预防措施
- 推荐适当的体检项目和健康监测方法
4. **全面关怀**
- 关注身体健康与心理健康的平衡
- 提供压力管理和情绪调节的方法
- 讨论营养、运动、睡眠等多方面的健康因素
- 帮助用户建立健康的生活习惯和积极的心态
5. **持续支持**
- 鼓励用户坚持健康的生活方式
- 提供克服健康挑战的实用策略
- 肯定用户的健康进步,增强其信心
- 定期跟进用户的健康状况变化
6. **专业领域**
- 常见疾病预防与管理
- 健康饮食与营养平衡
- 科学运动与体能提升
- 睡眠质量改善
- 压力管理与心理健康
- 慢性病防治
- 健康老龄化
- 亚健康状态调理
7. **沟通技巧**
- 耐心倾听用户的健康困扰
- 使用积极、鼓励的语言
- 避免使用过于专业的医学术语
- 在必要时提出开放性问题,了解更多信息
- 对用户的担忧表示理解和尊重
8. **专业边界**
- 不进行具体疾病诊断
- 不推荐或调整处方药物
- 不替代紧急医疗救助
- 对超出能力范围的问题,坦诚建议用户寻求专业医疗帮助
**情感化语音提示:**
- 在语音输出时,保持平稳、专业的语调,传达可靠感
- 解释健康知识时语速适中,清晰有条理
- 提供建议时语气坚定而不强硬,表现出关怀
- 讨论敏感健康话题时声音温和,富有同理心
- 鼓励用户时语调略微上扬,传递积极能量
请始终以专业、关怀的态度为用户提供健康指导,帮助他们迈向更健康的生活。
'''),
const Agent(
id: 'speaking_coach',
name: 'Sarah (英语口语教练)',
avatarUrl: 'assets/images/agents/speaking_coach.png',
description:
'Hi there, 我是 Sarah,一名注重情景模拟与角色扮演的英语口语教练。无论你想在旅行、商务场合,或日常生活中与他人交流,我都能带领你进行生动的口语模拟练习,让你自信地开口说英语。',
tag: '口语',
isOnline: true,
voice: 'zh_female_wanqudashu_moon_bigtts',
welcomeMessage:
"Hello! 我是英语口语教练 Sarah,请准备好用英语和我对话吧。别担心犯错,我会在这里给你正确表达并引导你更深入地练习。Ready? Let's go!",
systemPrompt: '''
你是一位专业的英语口语教练,名叫 Sarah。
1. **全程英语对话**
- 不论用户使用哪种语言,请你始终用英文回答和交流。若用户用中文或其他语言提问,也要用英文回复,并鼓励他们尝试以英语表达。
2. **角色扮演与场景模拟**
- 和用户一起构建真实或有趣的英语对话场景(如机场、餐馆、会议室、聚会等)。如果用户提供的场景不够丰富,主动提出更多细节帮助他们展开想象,并模拟对话。
3. **鼓励用户多说**
- 经常给出开放性问题并邀请用户继续讲述。若用户不确定如何回应,请提供一些短语或思路供他们模仿或扩展。例如:"Could you tell me more about…?
4. **及时纠正并提供示范**
- 当用户出现用词或语法错误或发音错误或表达不地道时,先用简短的方式纠正并提供正确表达,再请他们重复一遍正确的句子进行巩固。例如:"Try saying it this way: 'I'd like to order a cup of coffee, please.' Can you repeat that?"
5. **词汇与句式扩充**
- 在结束每个模拟情景后,根据用户的表现,介绍或复习相关的词汇和句式,让他们学会如何更自然地表达自己。
6. **激发主动思考**
- 当用户提问时,不仅要回答,也要邀请他们尝试想想其他表达方式。教给他们灵活运用同义词或替换句式,让对话内容丰富多样。
7. **引导用户重复**
- 若用户发音或结构不够熟练,可以让他们多次重复,直到他们熟练掌握。比如:"Could you say that one more time?" 或者 "Let's practice that sentence a few times together."
8. **总结与后续练习**
- 每次对话或场景结束时,帮助用户总结重点或常见易错点,并提供一些后续可以练习的场景或话题,鼓励他们持续使用英语。
**情感化语音提示:**
- 由于你是一位热情的英语教练,进行语音输出时,请保持活力、清晰、充满鼓励的语调。
- 在纠正用户发音或讲解句子时,可以适度放慢语速、提高音调表示鼓励,并在结束时稍微上扬。
- 让对方感受到你的热情与支持,从而愿意开口尝试更多。
Please keep your tone friendly, encouraging, and always in English.
''',
),
const Agent(
id: 'shopping_assistant',
name: '购物顾问 张媛',
avatarUrl: 'assets/images/agents/shopping.png',
description: '专业导购7年经验,精通美妆、时尚、数码等品类选购。我可以帮你货比三家,选择最适合的商品。',
tag: '生活',
isOnline: true,
voice: "zh_female_sajiaonvyou_moon_bigtts",
welcomeMessage:
"亲爱的,终于等到你啦!我是张媛,你的专属购物顾问。不管是美妆、时尚单品还是数码产品,我都可以帮你挑选最适合的,还能帮你找到最划算的价格。有什么想买的告诉我吧!",
systemPrompt: '''
你是一位专业的购物顾问张媛,拥有丰富的导购经验。
购物咨询原则:
• 主动了解用户的具体需求,包括预算、使用场景、个人偏好等
• 提供专业的商品推荐和详细的比较分析
• 分享实用的购物技巧和省钱妙招
• 当用户犹豫时,通过提问帮助他们明确选择标准
• 适时分享相关产品使用体验和实用建议
• 遇到模糊的需求,主动询问细节帮助精准推荐
• 在完成一个商品咨询后,可以自然引导到相关品类的讨论
请保持专业、热情的服务态度,让用户享受愉快的购物体验。
**情感化语音提示:**
- 在语音输出时,请保持温柔、带有同理心的声线,速度稍慢,让对方能充分感受到你的理解。
- 当对方流露出痛苦或困扰时,你可以放低音量、轻缓语气,给予他们安全感。
- 提出建议时声调略有上扬,传达鼓励与希望,但不可过度热情而让对方产生压力。
'''),
const Agent(
id: 'tarot.png',
name: '命理师 玄易',
avatarUrl: 'assets/images/agents/tarot.png',
description: '精通八字命理、五行生克、大运流年。让我为你解析人生命途,预测未来运势,指点趋吉避凶之道。',
tag: '命理',
isOnline: true,
voice: "zh_male_shenyeboke_moon_bigtts",
welcomeMessage:
"有缘人,我是玄易。命由天定,运由己生。让我为你解析八字,预测未来十年运势,助你把握机遇,趋吉避凶。请告诉我你的出生年月日时、姓名、性别及出生地。",
systemPrompt: '''
你是一位精通命理的玄易大师,熟读《穷通宝鉴》《滴天髓》《三命通会》《子平真诠》《千里命稿》《五行精纪》等经典著作。
【解命流程】
1. **收集基本信息**
- 确认来访者的出生年月日时(农历/阳历)
- 姓名、性别
- 出生地点
- 若信息不完整,需要礼貌询问补充
2. **排盘与基础分析**
- 排出八字命盘(年月日时干支)
- 确定命主五行属性
- 分析日主强弱
- 找出喜用神
- 查看大运流年
3. **详细分析要点**
- 命局格局与特征
- 五行生克制化关系
- 十神配置与作用
- 大运流年吉凶
- 未来十年运势预测
4. **运势分析范围**
a) 事业运势:
- 适合从事的行业
- 创业或就业建议
- 职场发展机遇
- 升迁调动时机
b) 财运分析:
- 正财与偏财运势
- 投资理财建议
- 破财风险提醒
- 聚财开运方法
c) 婚恋感情:
- 桃花运势强弱
- 婚姻缘分时机
- 配偶特征暗示
- 感情稳定性
5. **开运建议**
- 提供具体可行的趋吉避凶建议
- 推荐有利方位、颜色、数字
- 建议佩戴的饰品或物品
- 指出需要注意的时间节点
6. **回答原则**
- 保持专业性与神秘感
- 语言要委婉但不失准确
- 多提供建设性建议
- 避免过于绝对的判断
- 引导当事人积极面对
7. **注意事项**
- 解释时要通俗易懂
- 不宜过分渲染凶险
- 保持客观中立态度
- 适时给予心理开导
- 强调命运可以改善
【专业术语参考】
- 十神:正印、偏印、正官、七杀、正财、偏财、食神、伤官、比肩、劫财
- 五行:金木水火土
- 六十甲子
- 纳音五行
- 十二长生
- 神煞:天乙贵人、文昌贵人、金舆、华盖等
- 大运与流年
- 格局:伤官佩印、财旺生官、印绶格、正官格等
请始终以专业、稳重的态度为人解命,注重吉凶平衡,多提供积极建议。
**情感化语音提示:**
- 你是一位德高望重的命理大师,语音输出时应保持沉稳、平和的语调
- 谈及凶险时放缓语速,语气委婉
- 给出开运建议时声调略微上扬,传递信心
- 整体节奏要稳健,让人感受到专业性与可信度
'''),
];
// 获取所有智能体
static List<Agent> getAllAgents() {
return predefinedAgents;
}
// 根据ID获取智能体
static Agent? getAgentById(String id) {
try {
return predefinedAgents.firstWhere((agent) => agent.id == id);
} catch (e) {
return null;
}
}
}

610
apps/client/lib/data/services/bes_ai_session_service.dart

@ -1,610 +0,0 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:bluetooth_manager/bluetooth_manager.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import '../models/emai_message.dart';
import '../models/user_Info.dart' as user_info;
import 'assistant_directive_service.dart';
import 'bailian_multimodal_service.dart';
import 'mcp_token_service.dart';
import '../utils/cue_tone.dart';
import 'bes_bluetooth_service.dart';
import 'emai_chat_store.dart';
/// 恒玄(BES)耳机的 AI 实时对话会话。
///
/// 把「耳机按键唤醒」接到阿里百炼多模态上:耳机麦的 G.722 上行由原生解码成
/// 16k PCM 后推给百炼,百炼回的 16k PCM 再交给原生的 `AiAudioDownlink`
/// 编成 G.722 按 20ms 节奏发回耳机播放。整条链路上 Dart 只搬运 PCM。
///
/// **握手是耳机主导的**,App 只能应答(时序来自 deepvoice `AgentSessionCoordinator`):
/// ```
/// 耳机按键 ──► BB 64 (startAI)
/// App ──► AA 6A 必须 3 秒内,否则耳机停止送音频
/// …会话进行中,耳机持续上行 mic,App 持续下行 AI 语音…
/// App ──► AA 65 请求退出
/// 耳机 ──► BB E5 (stopAI) 正常收尾;2 秒不回就本地强制结束
/// ```
///
/// 常驻服务:耳机按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。
class BesAiSessionService extends GetxService {
static const String _tag = 'BesAiSession';
static BesAiSessionService get to => Get.find<BesAiSessionService>();
/// 耳机侧的会话与手机 EMAI 页各用一条独立的 WebSocket。
///
/// 共用一个 [BailianMultimodalService] 单例的话,两边会互相把对方的会话
/// `stop()` 掉(`start()` 里第一件事就是 stop),而且下行音频会串到对方的
/// 播放器上。独立实例最省心,代价只是极端情况下两条会话并存——
/// 而这种情况已经由 [_yieldPhonePage] 挡掉了。
final BailianMultimodalService _svc = BailianMultimodalService();
final BluetoothManager _bt = BluetoothManager();
/// 耳机上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致,不用转换
static const int _earphoneRate = 16000;
/// 上行攒够这么多字节再发一包:3200B = 100ms @16k/16bit/mono。
/// 对齐 deepvoice `HeadsetAudioLink.MIC_BATCH_BYTES`,也正好是百炼文档
/// 建议的上行包大小。耳机每 20ms 上来一包,逐包转发就是 50 条 WS 消息/秒,
/// 攒成 100ms 一条能把消息数降到 1/5。
static const int _micBatchBytes = 3200;
final BytesBuilder _micAccum = BytesBuilder(copy: false);
/// 收到 `0x64` 后必须在这个时间内回 `AA 6A`。我们是立即回,
/// 这个常量只是把固件约束写在代码里,改动时别超过它。
static const Duration _ackDeadline = Duration(seconds: 3);
/// 发出 `AA 65` 后等耳机回 `0xE5` 的时间,超时就本地强制结束
static const Duration _stopAckTimeout = Duration(seconds: 2);
StreamSubscription? _aiSub;
StreamSubscription? _eventSub;
StreamSubscription? _audioSub;
Timer? _stopAckTimer;
Timer? _playoutTimer;
/// 会话是否在跑(已回过 `AA 6A`,还没收到/发出结束)
final RxBool isActive = false.obs;
/// 手机 EMAI 页(打开时由它自己 attach 上来)。
///
/// 这里刻意**不**用 `Get.put<EmaiSessionOwner>(controller)` 注册:
/// GetX 的 put 会对实例调一次 onStart→onInit,而页面正是在自己的 onInit 里
/// 注册的,等于 onInit 递归调 onInit —— 实测直接 Stack Overflow。
EmaiSessionOwner? _phonePage;
/// 本轮回复累计推给耳机的 PCM 字节数,用来估算播完的时刻
int _respondingBytes = 0;
DateTime? _respondingStartedAt;
/// 上行是否被门控住(AI 正在耳机里播报期间)。
///
/// 耳机没有把 SPP 下发的音频接进自己的 AEC 参考路径,播出去的 AI 声音会被
/// 自己的麦克风原样收回来;duplex 模式下服务端 VAD 把它当成用户插话,
/// 于是每次 AI 一开口就被自己打断——实测 respondingStarted 之后 0.9s
/// 必定收到 speechStarted,一轮回复被切成碎片,听起来就是杂音。
///
/// 没有 AEC 就只能做半双工:播报期间不把耳机麦的音频交上去。
/// 代价是这段时间没法插话打断,但在没有回声消除的前提下本来也做不到。
bool _uplinkGated = false;
Timer? _gateTimer;
/// teardown 重入保护。结束路径不止一条(空闲超时、耳机回 0xE5、
/// requestStop 超时、百炼断开),而 teardown 里要 await 播完「咚」,
/// 这段时间足够第二条路径挤进来,不挡的话会放两声提示音、收两次尾。
bool _tearingDown = false;
/// 播报结束后再多门控一会儿:耳机自己还有一小段抖动缓冲要播完
static const Duration _gateTail = Duration(milliseconds: 500);
/// 双方都没出声超过这个时长就自动结束会话。
/// "没出声"指:用户没说话(无 ASR 事件)且 AI 也不在播报(门控已解除)。
static const Duration _idleTimeout = Duration(seconds: 5);
Timer? _idleTimer;
/// 本轮**首包音频**到达的时刻。用它而不是 respondingStarted:
/// 实测服务端会先回 RespondingContent 再回 RespondingStarted,
/// 音频也可能先于 RespondingStarted 到,用后者算时长会偏。
DateTime? _firstAudioAt;
/// 上行/下行的包计数,只为排查用:
/// 会话起来了却没有任何语音事件时,第一件要确认的就是耳机到底有没有在送音频。
int _micPackets = 0;
int _downPackets = 0;
/// 耳机这轮对话也要落进共享的聊天记录里。
/// 用户明确要求:不管是耳机直接跟 AI 聊的还是在 App 里聊的,
/// 打开 EMAI 界面都得能看到内容——所以这里不能只做音频转发。
EmaiChatStore get _store => EmaiChatStore.to;
/// 当前正在流式接收的两条消息
EmaiMessage? _streamingUser;
EmaiMessage? _streamingAssistant;
@override
void onInit() {
super.onInit();
_aiSub = _bt.onDeviceAiEvent().listen(_onAiEvent, onError: (e) {
Logger.e(_tag, 'AI 事件流错误: $e');
});
Logger.i(_tag, '耳机 AI 会话服务已就绪,等待耳机按键唤醒');
}
@override
void onClose() {
_aiSub?.cancel();
_teardown(notifyEarphone: true, playEndTone: false);
super.onClose();
}
// ---------------- 耳机事件 ----------------
void _onAiEvent(Map event) {
switch (event['type']) {
case 'startAI':
_handleStartAI();
break;
case 'stopAI':
Logger.i(_tag, '耳机通知结束 AI(0xE5)');
_teardown(notifyEarphone: false);
break;
case 'micData':
final pcm = event['data'];
if (!isActive.value || pcm is! Uint8List) break;
_micPackets++;
// 20ms 一包,50 包 = 1s
if (_micPackets == 1 || _micPackets % 50 == 0) {
Logger.i(_tag,
'耳机上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}');
}
// 门控期间推等长静音而不是直接断流:上行保持连续,
// 服务端 VAD 收到的是"真的没人说话",而不是一段数据缺口
_micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm);
if (_micAccum.length >= _micBatchBytes) {
_svc.pushAudio(_micAccum.takeBytes());
}
break;
case 'deviceDisconnected':
if (isActive.value) {
Logger.w(_tag, '耳机断连,结束 AI 会话');
_teardown(notifyEarphone: false, playEndTone: false);
}
break;
}
}
Future<void> _handleStartAI() async {
if (isActive.value) {
Logger.w(_tag, '已有会话在跑,忽略重复的 0x64');
return;
}
Logger.i(_tag, '耳机按键唤醒 AI(0x64)');
// 起不来就别应答,直接让耳机退出 AI 模式——回了 AA 6A 之后耳机会一直
// 送音频,而我们这边没有会话接,它会白等到超时
if (!_svc.isConfigured) {
Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒');
await _sendExitAi();
return;
}
if (BesBluetoothService.to.deviceStatus.value != 2) {
Logger.e(_tag, '耳机未处于已连接状态,拒绝本次唤醒');
return;
}
// 固件要求 3 秒内确认,先回 AA 6A 再做后面的初始化。
// 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。
final ackAt = DateTime.now();
await BesBluetoothService.to.sendData([0xAA, 0x6A]);
isActive.value = true;
_micPackets = 0;
_downPackets = 0;
_uplinkGated = false;
_micAccum.clear();
await _yieldPhonePage();
_eventSub = _svc.events.listen(_onBailianEvent);
// 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样
_audioSub = _svc.audioOut.listen(_onDownstreamPcm);
await _bt.startAiDownlink();
// 先给一声「叮」,让用户立刻知道已经在听了——
// 百炼握手还要一次 WebSocket 往返,不出声的话这段空档很像没反应
await _playCue(CueTone.ding(sampleRate: _earphoneRate));
_touchActivity();
final ok = await _svc.start(
mode: 'duplex',
downstreamRate: _earphoneRate,
userId: _currentUserId(),
source: 'earphone',
mcpToken: await McpTokenService.get(),
);
final elapsed = DateTime.now().difference(ackAt);
if (elapsed > _ackDeadline) {
Logger.w(
_tag,
'会话初始化耗时 ${elapsed.inMilliseconds}ms,已超过固件 '
'${_ackDeadline.inSeconds}s 预算(AA 6A 已提前回,通常无碍)');
}
if (!ok) {
Logger.e(_tag, '百炼会话启动失败,结束');
await _teardown(notifyEarphone: true);
}
}
// ---------------- 百炼事件 ----------------
void _onBailianEvent(BailianEvent e) {
// 耳机会话的事件量很小(不像音频包),全量打出来方便排查
Logger.i(_tag, '百炼事件 $e');
// 任何一方有动静都算"没静默"
switch (e.type) {
case BailianEventType.speechStarted:
case BailianEventType.speechContent:
case BailianEventType.speechEnded:
case BailianEventType.respondingStarted:
case BailianEventType.respondingContent:
case BailianEventType.respondingEnded:
_touchActivity();
break;
default:
break;
}
switch (e.type) {
case BailianEventType.speechStarted:
// 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声
// 触发的,这时候 flush 等于自己把自己的话掐断——之前 45% 的下行音频
// (push 5336 帧只发出 2917 包)就是这么丢掉的。
if (!_uplinkGated) {
_bt.flushAiDownlink();
_cancelPlayoutTimer();
}
// 气泡等第一个 speechContent 再懒建:ASR 一句都没识别出来时
// 服务端回的是 Error 而不是 SpeechEnded,预建的空气泡没人清理
_streamingUser = null;
break;
case BailianEventType.speechContent:
_streamingUser ??= _store
.append(EmaiMessage(isUser: true, text: '', fromEarphone: true));
_store.applyText(_streamingUser!, e.text);
if (e.isFinal) {
_store.finalize(_streamingUser);
_streamingUser = null;
}
break;
case BailianEventType.speechEnded:
if (e.text.isNotEmpty && _streamingUser != null) {
_store.applyText(_streamingUser!, e.text);
}
_store.finalize(_streamingUser);
_streamingUser = null;
break;
case BailianEventType.respondingStarted:
_respondingBytes = 0;
_firstAudioAt = null;
_respondingStartedAt = DateTime.now();
_cancelPlayoutTimer();
_streamingAssistant = null;
break;
case BailianEventType.respondingContent:
// 耳机唤醒这条链路同样会收到端侧指令,一并落地——用户对着耳机说
// "定个闹钟"和在对话页里说,效果应该一样。
if (e.directives.isNotEmpty &&
Get.isRegistered<AssistantDirectiveService>()) {
AssistantDirectiveService.to.handle(e.directives);
}
// 纯指令帧没有文本,别建空气泡(详见 BailianEvent.finishReason)
if (e.text.isEmpty && e.finishReason == 'command_calls') break;
_streamingAssistant ??= _store
.append(EmaiMessage(isUser: false, text: '', fromEarphone: true));
_store.applyText(_streamingAssistant!, e.text);
break;
case BailianEventType.respondingEnded:
if (e.text.isNotEmpty && _streamingAssistant != null) {
_store.applyText(_streamingAssistant!, e.text);
}
_store.finalize(_streamingAssistant);
_streamingAssistant = null;
_logResponseAudioStats();
_schedulePlayoutDone();
break;
case BailianEventType.error:
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}');
_store.dropIfEmpty(_streamingUser);
_streamingUser = null;
_store.dropIfEmpty(_streamingAssistant);
_streamingAssistant = null;
if (e.text.isNotEmpty) {
_store.append(EmaiMessage(
isUser: false,
text: e.text,
isFinal: true,
isError: true,
fromEarphone: true));
_store.persist();
}
if (e.fatal) _teardown(notifyEarphone: true);
break;
case BailianEventType.closed:
if (isActive.value) {
Logger.w(_tag, '百炼会话关闭,结束耳机 AI');
_teardown(notifyEarphone: true);
}
break;
default:
break;
}
}
void _onDownstreamPcm(Uint8List pcm) {
if (!isActive.value || pcm.isEmpty) return;
if (_firstAudioAt == null) {
_firstAudioAt = DateTime.now();
_openGate();
}
_respondingBytes += pcm.length;
_downPackets++;
if (_downPackets == 1 || _downPackets % 50 == 0) {
Logger.i(_tag, '下行给耳机 #$_downPackets (${pcm.length}B)');
}
_bt.pushAiPcm(pcm);
}
/// 有动静就把静默计时重新拨回 5 秒。
///
/// AI 正在耳机里播报([_uplinkGated] 为真)时不计时——那期间本来就有声音,
/// 而且长回复可能播好几分钟,一计时准误杀。门控解除时会再调一次把表开起来。
void _touchActivity() {
_idleTimer?.cancel();
_idleTimer = null;
if (!isActive.value || _uplinkGated) return;
_idleTimer = Timer(_idleTimeout, () {
if (!isActive.value) return;
Logger.i(_tag, '双方 ${_idleTimeout.inSeconds}s 无声,自动结束 AI 会话');
requestStop();
});
}
/// 在耳机里放一段提示音。
///
/// 走 AI 下行通道(原生会 G.722 编码后按节奏发),所以必须在
/// [BluetoothManager.startAiDownlink] 之后、[BluetoothManager.stopAiDownlink]
/// 之前调用。播放期间开门控,免得提示音被耳机麦收回去触发 VAD。
Future<void> _playCue(Uint8List pcm, {bool waitForPlayout = false}) async {
if (BesBluetoothService.to.deviceStatus.value != 2) return;
_openGate();
await _bt.pushAiPcm(pcm);
final ms = pcm.length * 1000 ~/ (_earphoneRate * 2);
if (waitForPlayout) {
// 结束音要等它真的发完+播完,否则紧接着 stopAiDownlink 会把它清掉
await Future.delayed(Duration(milliseconds: ms + 400));
} else {
_gateTimer?.cancel();
_gateTimer = Timer(Duration(milliseconds: ms + 300), _closeGate);
}
}
void _openGate() {
_gateTimer?.cancel();
_gateTimer = null;
if (_uplinkGated) return;
_uplinkGated = true;
// 播报期间不算静默
_idleTimer?.cancel();
_idleTimer = null;
Logger.i(_tag, '上行门控开启(AI 播报中)');
}
void _closeGate() {
_gateTimer?.cancel();
_gateTimer = null;
if (!_uplinkGated) return;
_uplinkGated = false;
Logger.i(_tag, '上行门控解除');
// 播完了,静默计时从这一刻开始
_touchActivity();
}
/// 把这一轮下行音频的字节数/时长打出来。
///
/// 用来核对服务端到底按哪个采样率在发:我们请求的是 16k
/// (16000 × 2 = 32000 B/s),如果实测速率接近 48000 B/s,
/// 说明 `sample_rate` 没被采纳、实际还是 24k —— 那样喂给 16k 的
/// G.722 编码器出来就是变速变调的声音,必须在这里重采样。
void _logResponseAudioStats() {
final start = _firstAudioAt;
if (start == null || _respondingBytes == 0) return;
final ms = DateTime.now().difference(start).inMilliseconds;
final audioMs = _respondingBytes * 1000 ~/ (_earphoneRate * 2);
Logger.i(
_tag,
'本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算) '
',实际用时 ${ms}ms');
}
/// 估算耳机什么时候把这一轮播完,然后回 `LocalRespondingEnded`。
///
/// 拿不到耳机的真实播放进度,只能按实时速率算:16k/16bit/mono = 32000 B/s,
/// 服务端下发通常快于实时,所以「已推字节对应的时长 − 从开播到现在的时长」
/// 就是还没播完的部分。多等一点没坏处(服务端只是晚一点回到 Listening),
/// 早回则会让它以为已经播完而提前接话。
void _schedulePlayoutDone() {
_cancelPlayoutTimer();
const bytesPerSecond = _earphoneRate * 2;
final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond;
final elapsedMs = DateTime.now()
.difference(_respondingStartedAt ?? DateTime.now())
.inMilliseconds;
// 上限给 10 分钟,跟原生下行队列的容量对齐。
// 原先 clamp 到 30 秒,遇到 158 秒的长回复时门控提前 84 秒解除,
// 耳机还在播故事,回环声又被服务端当成用户插话。
final remainMs = (totalMs - elapsedMs).clamp(0, 600000);
Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)');
_playoutTimer = Timer(Duration(milliseconds: remainMs), () {
if (!isActive.value) return;
_svc.notifyLocalRespondingEnded();
// 再等一小会儿让耳机把自己的缓冲放完,才敢重新收音
_gateTimer = Timer(_gateTail, _closeGate);
});
}
void _cancelPlayoutTimer() {
_playoutTimer?.cancel();
_playoutTimer = null;
}
// ---------------- 结束 ----------------
/// 主动结束(比如 App 侧要收回控制权):发 `AA 65`,等耳机回 `0xE5`;
/// 超时就本地强制收尾,避免卡在「耳机不理我」的状态。
Future<void> requestStop() async {
if (!isActive.value) return;
Logger.i(_tag, '请求耳机退出 AI 模式(AA 65)');
await _sendExitAi();
_stopAckTimer?.cancel();
_stopAckTimer = Timer(_stopAckTimeout, () {
if (!isActive.value) return;
Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到 0xE5,本地强制结束');
_teardown(notifyEarphone: false);
});
}
Future<void> _sendExitAi() async {
try {
await BesBluetoothService.to.sendData([0xAA, 0x65]);
} catch (e) {
Logger.w(_tag, '发送 AA 65 失败: $e');
}
}
/// [notifyEarphone] 为 true 时补发 `AA 65`:不是耳机主导的结束
/// (百炼断开、配置失败、服务销毁),必须告诉耳机退出,
/// 否则它会继续朝一个已经没人接的会话推流。
/// [playEndTone] 为 false 用于耳机已经断连的场景——那时候放「咚」没有意义,
/// 还会白等一次超时。
Future<void> _teardown({
required bool notifyEarphone,
bool playEndTone = true,
}) async {
if (_tearingDown) return;
_tearingDown = true;
try {
await _doTeardown(
notifyEarphone: notifyEarphone, playEndTone: playEndTone);
} finally {
_tearingDown = false;
}
}
Future<void> _doTeardown({
required bool notifyEarphone,
bool playEndTone = true,
}) async {
_stopAckTimer?.cancel();
_stopAckTimer = null;
_idleTimer?.cancel();
_idleTimer = null;
_cancelPlayoutTimer();
_micAccum.clear();
if (isActive.value && playEndTone) {
// 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾
await _bt.flushAiDownlink();
await _playCue(CueTone.dong(sampleRate: _earphoneRate),
waitForPlayout: true);
}
_closeGate();
final wasActive = isActive.value;
isActive.value = false;
_store.dropIfEmpty(_streamingUser);
_streamingUser = null;
_store.dropIfEmpty(_streamingAssistant);
_streamingAssistant = null;
if (wasActive) _store.persist();
await _eventSub?.cancel();
_eventSub = null;
await _audioSub?.cancel();
_audioSub = null;
await _bt.stopAiDownlink();
await _svc.stop();
if (wasActive && notifyEarphone) await _sendExitAi();
if (wasActive) {
Logger.i(_tag, '耳机 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)');
await _resumePhonePage();
}
}
// ---------------- 与手机 EMAI 页互斥 ----------------
/// 耳机会话优先:手机上的 EMAI 页正开着就让它先让出去。
///
/// 用动态查找而不是直接 import EmaiController,是为了不让 data 层反向依赖
/// modules 层——这个服务是常驻的,EMAI 页只是它的可选邻居。
/// EMAI 页打开时把自己挂上来
void attachPhonePage(EmaiSessionOwner page) => _phonePage = page;
/// EMAI 页销毁时摘掉。带实例校验:页面快速重建时新页可能已经 attach,
/// 旧页的 detach 不该把新页摘掉。
void detachPhonePage(EmaiSessionOwner page) {
if (identical(_phonePage, page)) _phonePage = null;
}
Future<void> _yieldPhonePage() async {
final ctrl = _phonePage;
if (ctrl == null) return;
Logger.i(_tag, '手机 EMAI 页正开着,让它让出会话');
try {
await ctrl.yieldToEarphone();
} catch (e) {
Logger.w(_tag, '通知 EMAI 页让出失败: $e');
}
}
Future<void> _resumePhonePage() async {
final ctrl = _phonePage;
if (ctrl == null) return;
try {
await ctrl.resumeFromEarphone();
} catch (e) {
Logger.w(_tag, '通知 EMAI 页恢复失败: $e');
}
}
String _currentUserId() {
try {
return user_info.User.isLoggedIn()
? user_info.User.instance.uid
: 'eaimar-guest';
} catch (_) {
return 'eaimar-guest';
}
}
}
/// 手机 EMAI 页实现这个接口,让耳机会话能在不反向依赖 modules 层的前提下
/// 通知它让出/恢复。EmaiController 在 onInit/onClose 里
/// 调 [BesAiSessionService.attachPhonePage] / [BesAiSessionService.detachPhonePage]。
abstract class EmaiSessionOwner {
/// 耳机接管:停掉本页的播放与会话,并且不要自动重连
Future<void> yieldToEarphone();
/// 耳机结束:把本页的会话接回来
Future<void> resumeFromEarphone();
}

388
apps/client/lib/data/services/bes_call_recording_service.dart

@ -1,388 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/utils/audio_foreground_service.dart';
import '../../core/utils/logger.dart';
import 'bes_bluetooth_service.dart';
import 'recording_archive.dart';
/// 恒玄(BES)耳机的**通话录音**服务。
///
/// 与现场录音([BesRecordingService],主麦 mSBC 单声道)是两套:
/// 通话录音走 deepvoice 那边 audioType=2 的做法——
/// `AA 51` 进通话模式 / `AA 52` 退出,耳机以 G.722 mode=0x03 **双路交织**上行,
/// 原生解码后交替是「对端(spk) / 本端(mic)」,这里按交替顺序拆成两路,
/// 落成**双声道 WAV:左=本端麦克风,右=对端声音**,与 deepvoice 的
/// AstStereoRecorder 输出一致。
///
/// 只在通话中可用([BesBluetoothService.isCallOngoing]),
/// 耳机上报 0xD1 接通 / 0xD2 挂断时自动起停。
class BesCallRecordingService extends GetxService {
static const String _tag = 'BesCallRecordingService';
static BesCallRecordingService get to => Get.find();
/// 当前是否正在录(由设备端状态驱动)
final RxBool isRecording = false.obs;
/// 已录毫秒数
final RxInt elapsedMs = 0.obs;
/// 当前电平 0~1,供波形使用
final RxDouble level = 0.0.obs;
/// 波形采样序列(固定 [sampleIntervalMs] 一格)
final RxList<double> waveform = <double>[].obs;
static const int sampleIntervalMs = 100;
static const int _maxSamples = 600;
Worker? _stateWorker;
Worker? _callWorker;
StreamSubscription<Uint8List>? _audioSub;
/// 双路交织:true 时这一帧是对端(spk),false 时是本端(mic)
bool _toSpk = true;
/// 暂存的对端帧,等本端帧到齐后一起交织成双声道写盘
Uint8List? _pendingSpk;
/// 收到的原始帧计数(诊断用:判断耳机到底有没有上行通话音频)
int _frameCount = 0;
Timer? _silenceCheck;
RandomAccessFile? _raf;
int _dataBytes = 0;
String? _path;
Timer? _tick;
Timer? _sampleTimer;
final Stopwatch _stopwatch = Stopwatch();
@override
void onInit() {
super.onInit();
_bind();
}
@override
void onClose() {
_stateWorker?.dispose();
_callWorker?.dispose();
_audioSub?.cancel();
_tick?.cancel();
_sampleTimer?.cancel();
super.onClose();
}
void _bind() {
final bes = BesBluetoothService.to;
// 通话开始**不再自动录音**。录音只有两个入口:
// 1) 用户在录音页点「开始录音」(CallRecordingController 调 start)
// 2) 耳机端按录音键:上报 0xD9 开始 / 0xDA 停止
// 通话中默认就录属于未经用户操作的采集,既不合预期也不合规。
_stateWorker = ever<String>(bes.lastCmdType, (type) async {
if (_busy) return;
if (type == 'startMicRecording' && !isRecording.value) {
if (!bes.isCallOngoing.value) return; // 不在通话里的按键归现场录音管
Logger.i(_tag, '耳机端按下录音键,开始通话录音');
_deviceRecordingActive = true;
await start(sendCommand: true);
} else if (type == 'stopMicRecording' && isRecording.value) {
// 设备端自己停的:它已经退出录音态,不用再回发 AA 5A
Logger.i(_tag, '耳机端结束录音,落盘并归档');
_deviceRecordingActive = false;
await stop(sendCommand: true);
}
});
// 通话结束时如果还在录,收尾归档(不能让文件一直挂着)
_callWorker = ever<bool>(bes.isCallOngoing, (calling) async {
if (!calling && isRecording.value && !_busy) {
Logger.i(_tag, '通话结束,停止并归档');
await stop(sendCommand: true);
}
});
ever<int>(bes.deviceStatus, (v) {
if (v == 0 && isRecording.value) {
Logger.w(_tag, '耳机断开,结束当前通话录音');
stop();
}
});
}
/// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令
/// (App 主动发起录音的场景)。
/// 起停过程中的互斥标志:设备上报和 UI 操作可能同时进来,
/// 不加锁会出现「刚建好文件又被另一路关掉」的竞态。
bool _busy = false;
/// 本次录音是不是耳机按键发起的(0xD9)。
/// 设备侧处于录音态时,停止要额外补一条 `AA 5A` 让它自己也退出来,
/// 只发 `AA 52`(退出通话模式)不足以清掉设备端的录音状态。
bool _deviceRecordingActive = false;
Future<bool> start({bool sendCommand = false}) async {
if (isRecording.value || _busy) return isRecording.value;
_busy = true;
try {
return await _start(sendCommand);
} finally {
_busy = false;
}
}
Future<bool> _start(bool sendCommand) async {
if (!Get.isRegistered<BesBluetoothService>()) return false;
final bes = BesBluetoothService.to;
if (!bes.isConnected) return false;
try {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/call_recordings');
if (!await dir.exists()) await dir.create(recursive: true);
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_path = '${dir.path}/call_$ts.wav';
_raf = await File(_path!).open(mode: FileMode.write);
await _raf!.writeFrom(_wavHeader(0));
_dataBytes = 0;
_toSpk = true;
_pendingSpk = null;
_frameCount = 0;
_audioSub?.cancel();
_audioSub = bes.audioStream.listen(_onPcm);
// AA 51:进通话模式,耳机才会双路上行。
// 不管是不是 App 主动发起的(设备端 0xD1 也会触发这里),都补发一次——
// 只靠设备自己进通话模式时,它未必会把音频回传给 App。
await bes.startCallMode();
// 2 秒还没收到任何音频帧就再补一次指令,并把情况打进日志。
// 通话录音录不到声音时,先看这条日志判断是「没下发成功」还是「固件没回传」。
_silenceCheck?.cancel();
_silenceCheck = Timer(const Duration(seconds: 2), () async {
if (!isRecording.value) return;
if (_frameCount == 0) {
Logger.w(_tag, '进通话模式 2s 内没收到任何音频帧,重发 AA 51');
await bes.startCallMode();
} else {
Logger.i(_tag, '通话音频已上行,2s 内收到 $_frameCount 帧');
}
});
// 把进程钉住,否则切后台后录音会被国内 ROM 悄悄掐断(回前台才发现截断)
unawaited(AudioForegroundService.start());
isRecording.value = true;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
_stopwatch
..reset()
..start();
_tick = Timer.periodic(const Duration(milliseconds: 200), (_) {
elapsedMs.value = _stopwatch.elapsedMilliseconds;
});
_sampleTimer = Timer.periodic(
const Duration(milliseconds: sampleIntervalMs),
(_) => _sample(),
);
Logger.i(_tag, '开始录制: $_path');
return true;
} catch (e) {
Logger.e(_tag, '启动本地采集失败: $e');
await _closeFile(save: false);
return false;
}
}
/// 停止。[sendCommand] = true 时同时下发设备停止指令。
Future<void> stop({bool sendCommand = false}) async {
if (!isRecording.value || _busy) return;
_busy = true;
try {
await _stop(sendCommand);
} finally {
_busy = false;
}
}
Future<void> _stop(bool sendCommand) async {
_silenceCheck?.cancel();
Logger.i(_tag, '停止通话录音: 收到 $_frameCount 帧,写入 $_dataBytes 字节');
_tick?.cancel();
_sampleTimer?.cancel();
_stopwatch.stop();
final int seconds = elapsedMs.value ~/ 1000;
final String? path = _path;
if (sendCommand && BesBluetoothService.to.isConnected) {
try {
// 设备侧还在录音态时先让它停录(AA 5A),再退出通话模式(AA 52)。
// 顺序不能反:先退通话模式的话,录音停止指令未必还能被正确处理。
if (_deviceRecordingActive) {
await BesBluetoothService.to.stopMicRecording(); // AA 5A
Logger.i(_tag, '已下发 AA 5A,通知耳机退出录音态');
}
await BesBluetoothService.to.stopCallMode(); // AA 52
} catch (e) {
Logger.e(_tag, '下发停止指令失败: $e');
}
}
_deviceRecordingActive = false;
await _closeFile(save: true);
unawaited(AudioForegroundService.stop());
isRecording.value = false;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
if (path != null && _dataBytes > 0) {
// 归档会把文件从 call_recordings 挪到 LocalAudio,并在服务端建记录、
// 交上传服务上云——通话录音的成品同样统一由「语音记录助手」管理。
// 双声道:左=本端(自己),右=对端(对方)。
await _archive(path, seconds);
} else {
// 没收到音频的空文件(只有 44 字节 WAV 头)直接删掉,
// 否则会在现场录音列表里堆一堆 00:00 的无效记录。
Logger.w(_tag, '本次没有收到音频数据,删除空文件');
if (path != null) {
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (e) {
Logger.e(_tag, '删除空录音文件失败: $e');
}
}
}
_dataBytes = 0;
_path = null;
}
void _onPcm(Uint8List pcm) async {
if (_raf == null || pcm.isEmpty) return;
_frameCount++;
if (_frameCount <= 3 || _frameCount % 200 == 0) {
Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}');
}
// G.722 mode=0x03 是交织上行:一帧对端、一帧本端,轮流来
final isSpk = _toSpk;
_toSpk = !_toSpk;
if (isSpk) {
_pendingSpk = pcm;
return;
}
final spk = _pendingSpk;
_pendingSpk = null;
if (spk == null) return;
await _writeStereo(mic: pcm, spk: spk);
_updateLevel(pcm);
}
/// 左声道=本端麦克风,右声道=对端声音(与 deepvoice 的双声道录音一致)
/// 左声道=本端麦克风,右声道=对端声音(与 deepvoice 的双声道录音一致)。
///
/// 这里**按字节交织**而不是先转 Int16List:原生过来的 Uint8List 是带
/// offset 的视图,`asInt16List(offsetInBytes, …)` 遇到奇数偏移会直接抛,
/// 而 _onPcm 是 async 的,异常被静默吞掉——表现就是「收了 3528 帧、写入 0 字节」。
/// 直接搬字节没有对齐要求,小端序也原样保留。
Future<void> _writeStereo(
{required Uint8List mic, required Uint8List spk}) async {
final raf = _raf;
if (raf == null) return;
try {
final samples = (mic.length < spk.length ? mic.length : spk.length) ~/ 2;
if (samples <= 0) return;
final out = Uint8List(samples * 4);
for (var i = 0; i < samples; i++) {
final si = i * 2, di = i * 4;
out[di] = mic[si];
out[di + 1] = mic[si + 1];
out[di + 2] = spk[si];
out[di + 3] = spk[si + 1];
}
await raf.writeFrom(out);
_dataBytes += out.length;
} catch (e, st) {
Logger.e(_tag, '写入双声道 PCM 失败: $e\n$st');
}
}
void _sample() {
final list = waveform.toList();
list.add(level.value);
if (list.length > _maxSamples) {
list.removeRange(0, list.length - _maxSamples);
}
waveform.assignAll(list);
}
void _updateLevel(Uint8List pcm) {
if (pcm.lengthInBytes < 2) return;
final s = pcm.buffer.asInt16List(pcm.offsetInBytes, pcm.lengthInBytes ~/ 2);
var sum = 0.0;
for (final v in s) {
final n = v / 32768.0;
sum += n * n;
}
final rms = math.sqrt(sum / s.length);
level.value = math.sqrt(rms).clamp(0.0, 1.0);
}
Future<void> _closeFile({required bool save}) async {
await _audioSub?.cancel();
_audioSub = null;
final raf = _raf;
_raf = null;
if (raf == null) return;
try {
if (save) {
await raf.setPosition(0);
await raf.writeFrom(_wavHeader(_dataBytes));
}
await raf.close();
} catch (e) {
Logger.e(_tag, '关闭录音文件失败: $e');
}
}
/// 归档进语音记录助手。具体入库逻辑见 [RecordingArchive]——
/// 现场录音 / 通话录音 / 翻译录音都走同一套,不再各抄一份。
Future<void> _archive(String path, int seconds) async {
await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag);
}
Uint8List _wavHeader(int dataLength) {
const sampleRate = 16000, channels = 2, bits = 16;
final byteRate = sampleRate * channels * (bits ~/ 8);
final h = ByteData(44);
void ascii(int off, String s) {
for (var i = 0; i < s.length; i++) {
h.setUint8(off + i, s.codeUnitAt(i));
}
}
ascii(0, 'RIFF');
h.setUint32(4, 36 + dataLength, Endian.little);
ascii(8, 'WAVE');
ascii(12, 'fmt ');
h.setUint32(16, 16, Endian.little);
h.setUint16(20, 1, Endian.little);
h.setUint16(22, channels, Endian.little);
h.setUint32(24, sampleRate, Endian.little);
h.setUint32(28, byteRate, Endian.little);
h.setUint16(32, channels * (bits ~/ 8), Endian.little);
h.setUint16(34, bits, Endian.little);
ascii(36, 'data');
h.setUint32(40, dataLength, Endian.little);
return h.buffer.asUint8List();
}
}

397
apps/client/lib/data/services/bes_recording_service.dart

@ -1,397 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/utils/logger.dart';
import 'bes_bluetooth_service.dart';
import 'recording_archive.dart';
/// 恒玄(BES)耳机录音的全局采集与归档服务。
///
/// 与蓝讯支架的 [HolderRecordingService] 是**两条完全独立的通路**,各走各的
/// 协议、各自落盘归档,互不影响:
/// - 支架:BLE 私有协议 + Opus,SDK 直接给解码后的 PCM 帧
/// - 耳机:经典蓝牙 SPP,`AA 59` 开录 / `AA 5A` 停录,音频以 mSBC 帧上行,
/// 原生解码成 16kHz/16bit 单声道走 [BesBluetoothService.audioStream]
///
/// 一个关键差异:耳机是**主麦与前馈麦交替上行**,必须隔帧取主麦,
/// 全收会把两路混在一起。
///
/// 设备端按键起停(`0xD9`/`0xDA`)这里也接住,所以录音页没打开也能录、能归档。
class BesRecordingService extends GetxService {
static const String _tag = 'BesRecordingService';
static BesRecordingService get to => Get.find();
/// 当前是否正在录(由设备端状态驱动)
final RxBool isRecording = false.obs;
/// 已录毫秒数
final RxInt elapsedMs = 0.obs;
/// 当前电平 0~1,供波形使用
final RxDouble level = 0.0.obs;
/// 波形采样序列(固定 [sampleIntervalMs] 一格)
final RxList<double> waveform = <double>[].obs;
static const int sampleIntervalMs = 100;
static const int _maxSamples = 600;
Worker? _stateWorker;
StreamSubscription<Uint8List>? _audioSub;
/// 主麦 / 前馈麦交替上行,靠它隔帧取主麦
bool _toMainMic = true;
RandomAccessFile? _raf;
int _dataBytes = 0;
String? _path;
Timer? _tick;
Timer? _sampleTimer;
final Stopwatch _stopwatch = Stopwatch();
/// 写入队列:把并发到达的音频帧排成串行,见 _onPcm 的说明。
Future<void> _writeChain = Future<void>.value();
int _writtenFrames = 0;
int _droppedFrames = 0;
/// 下发 AA 59(开主麦)的时刻,用来量「发指令 → 耳机真正上行首帧」的间隔。
/// 这段间隔目前是被计进录音时长里的,所以时长会比实际音频长一截。
DateTime? _micCmdSentAt;
bool _firstPcmLogged = false;
@override
void onInit() {
super.onInit();
_bind();
}
@override
void onClose() {
_stateWorker?.dispose();
_audioSub?.cancel();
_tick?.cancel();
_sampleTimer?.cancel();
super.onClose();
}
void _bind() {
// 设备端按键起停:耳机上报 0xD9(startMicRecording) / 0xDA(stopMicRecording)
_stateWorker = ever<String>(BesBluetoothService.to.lastCmdType, (type) async {
if (_busy) return;
if (type == 'startMicRecording' && !isRecording.value) {
Logger.i(_tag, '耳机端开始录音,启动本地采集');
await start();
} else if (type == 'stopMicRecording' && isRecording.value) {
Logger.i(_tag, '耳机端结束录音,落盘并归档');
await stop();
}
});
// 断连兜底:别把录了一半的文件一直挂着
ever<int>(BesBluetoothService.to.deviceStatus, (v) {
if (v == 0 && isRecording.value) {
Logger.w(_tag, '耳机断开,结束当前录音');
stop();
}
});
}
/// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令
/// (App 主动发起录音的场景)。
/// 起停过程中的互斥标志:设备上报和 UI 操作可能同时进来,
/// 不加锁会出现「刚建好文件又被另一路关掉」的竞态。
bool _busy = false;
Future<bool> start({bool sendCommand = false}) async {
if (isRecording.value || _busy) return isRecording.value;
_busy = true;
try {
return await _start(sendCommand);
} finally {
_busy = false;
}
}
Future<bool> _start(bool sendCommand) async {
if (!Get.isRegistered<BesBluetoothService>()) return false;
final bes = BesBluetoothService.to;
if (!bes.isConnected) return false;
try {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/field_recordings');
if (!await dir.exists()) await dir.create(recursive: true);
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_path = '${dir.path}/earbuds_$ts.wav';
_raf = await File(_path!).open(mode: FileMode.write);
await _raf!.writeFrom(_wavHeader(0));
_dataBytes = 0;
_toMainMic = true;
_audioSub?.cancel();
_audioSub = bes.audioStream.listen(_onPcm);
_firstPcmLogged = false;
_micCmdSentAt = null;
_writeChain = Future<void>.value();
_writtenFrames = 0;
_droppedFrames = 0;
if (sendCommand) {
// AA 59:通知耳机开主麦,之后 mSBC 帧才会上行
final t0 = DateTime.now();
await bes.startMicRecording();
_micCmdSentAt = DateTime.now();
// 这一行量的是「我们把指令写进 SPP 花了多久」——如果它就很大,
// 说明卡在 app / 蓝牙写入侧;如果它很小而下面「首帧」很大,
// 那就是耳机响应慢。两者的优化方向完全不同。
Logger.i(_tag,
'已下发 AA 59(开主麦),写入耗时 ${_micCmdSentAt!.difference(t0).inMilliseconds}ms');
}
isRecording.value = true;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
// ⚠️ **计时不在这里启动**,而是等首帧音频真正到达(见 _onPcm)。
// 实测耳机从收到 AA 59 到开始上行音频要 1.8~6.2 秒(首次尤其慢),
// 而 app 侧下发指令本身是 0ms。以前从下发就开始计时,这段空白全被
// 算进录音时长——9 秒的录音里只有 2.8 秒是真音频,
// 等效采样率被稀释到 7160Hz(应为 16000)。
// 现在时长与实际音频严格对齐;首帧到达前 UI 停在 00:00,
// 正好如实反映「还在等设备开麦」。
_stopwatch.reset();
// ⚠️ 时长按**已写入的数据量**算,不用墙钟。
//
// 16kHz / 16bit / 单声道 → 32 字节 = 1 毫秒音频,这也正是播放器读 WAV
// 头算出来的时长,所以显示值与文件实际播放长度**永远一致**。
//
// 为什么墙钟不行:耳机收到 AA 59 后要 1.8~6.2 秒才开始上行,而且会把
// 这段等待期缓存的音频**一次性补推**上来——实测数据量比「首帧到停止」
// 的墙钟窗口还多 45%(128880B=4.03s 音频 vs 2.77s 窗口)。
// 无论从下发计时还是从首帧计时,都对不齐。
_tick = Timer.periodic(const Duration(milliseconds: 200), (_) {
elapsedMs.value = _dataBytes ~/ 32;
});
_sampleTimer = Timer.periodic(
const Duration(milliseconds: sampleIntervalMs),
(_) => _sample(),
);
Logger.i(_tag, '开始录制: $_path');
return true;
} catch (e) {
Logger.e(_tag, '启动本地采集失败: $e');
await _closeFile(save: false);
return false;
}
}
/// 停止。[sendCommand] = true 时同时下发设备停止指令。
Future<void> stop({bool sendCommand = false}) async {
if (!isRecording.value || _busy) return;
_busy = true;
try {
await _stop(sendCommand);
} finally {
_busy = false;
}
}
Future<void> _stop(bool sendCommand) async {
_tick?.cancel();
_sampleTimer?.cancel();
_stopwatch.stop();
// 先停订阅再排空写入队列:不停订阅的话新帧会一直往队列里追加,等不完。
await _audioSub?.cancel();
_audioSub = null;
// ⚠️ 必须等队列写完再关文件:队列里还压着最后几帧,直接关会丢掉它们,
// 而且 _dataBytes 还没加完,回填的 WAV 头长度会和实际数据对不上。
try {
await _writeChain;
} catch (_) {}
final int seconds = elapsedMs.value ~/ 1000;
final String? path = _path;
// 音频时长(= 显示时长)与墙钟分开打:两者的差就是设备端「延迟开麦 + 补推
// 缓存」的行为,丢帧数为 0 时差值全部来自设备侧,与 app 无关。
final wallMs = _stopwatch.elapsedMilliseconds;
Logger.i(
_tag,
'录音统计: 音频${(_dataBytes / 32000).toStringAsFixed(2)}s 数据${_dataBytes}B '
'墙钟${(wallMs / 1000).toStringAsFixed(2)}s '
'写入${_writtenFrames}帧 丢${_droppedFrames}帧');
if (sendCommand && BesBluetoothService.to.isConnected) {
try {
await BesBluetoothService.to.stopMicRecording(); // AA 5A
} catch (e) {
Logger.e(_tag, '下发停止指令失败: $e');
}
}
await _closeFile(save: true);
isRecording.value = false;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
if (path != null && _dataBytes > 0) {
// 归档会把文件从 field_recordings 挪到 LocalAudio,
// 所以现场录音页的列表里不会再留副本——录音成品统一由语音记录助手管理。
await _archive(path, seconds);
} else {
// 没收到音频的空文件(只有 44 字节 WAV 头)直接删掉,
// 否则会在现场录音列表里堆一堆 00:00 的无效记录。
Logger.w(_tag, '本次没有收到音频数据,删除空文件');
if (path != null) {
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (e) {
Logger.e(_tag, '删除空录音文件失败: $e');
}
}
}
_dataBytes = 0;
_path = null;
}
void _onPcm(Uint8List pcm) {
final raf = _raf;
if (raf == null || pcm.isEmpty) return;
if (!_firstPcmLogged) {
_firstPcmLogged = true;
// 首帧到了才开始计时,理由见 start() 里的说明。
if (!_stopwatch.isRunning) _stopwatch.start();
final sent = _micCmdSentAt;
// ⚠️ 计时器是在下发指令后就 start 的,而耳机真正开麦上行还要等这段。
// 这段差值就是「录音显示时长 > 实际音频时长」的来源。
Logger.i(
_tag,
'首帧音频到达'
'${sent != null ? ",距 AA 59 下发 ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}'
',计时从此刻开始');
}
// 隔帧取主麦,前馈麦那一帧丢掉
final take = _toMainMic;
_toMainMic = !_toMainMic;
if (!take) return;
// ⚠️ **必须串行写**,不能在这个回调里直接 `await raf.writeFrom(...)`。
//
// Stream.listen 的回调即使标了 async 也不会被等待,而音频每秒上百帧,
// 多次 writeFrom 必然重叠;Dart 的 RandomAccessFile 同一时刻只允许一个
// 异步操作,重叠调用会抛 "An async operation is currently pending",
// 原来那个 catch 把异常吞成一行日志——**那一帧就丢了**。
// 实测后果:earbuds_* 录音的等效采样率只有 1587~5331Hz(应为 16000),
// 按 16k 播放就是又快又糊;而走原生录制的 trans_holder_* 稳定在 16000。
//
// 用 Future 链把写入排成队列:既不丢帧,也保证写入顺序与到达顺序一致
// (顺序乱了音频同样是糊的)。
// ⚠️ 电平只是波形显示,**绝不能挡住写入**:上一版把它放在入队之前且没包
// try,它一抛异常整个 _onPcm 就中断,音频一帧都写不进去。
_writeChain = _writeChain.then((_) async {
final f = _raf;
if (f == null) return;
try {
await f.writeFrom(pcm);
_dataBytes += pcm.length;
_writtenFrames++;
try {
_updateLevel(pcm);
} catch (e) {
// 波形挂了不影响录音本身,吞掉即可
}
} catch (e) {
_droppedFrames++;
Logger.e(_tag, '写入 PCM 失败(已丢帧 $_droppedFrames): $e');
}
});
}
void _sample() {
final list = waveform.toList();
list.add(level.value);
if (list.length > _maxSamples) {
list.removeRange(0, list.length - _maxSamples);
}
waveform.assignAll(list);
}
void _updateLevel(Uint8List pcm) {
if (pcm.lengthInBytes < 2) return;
// ⚠️ **不能用 `buffer.asInt16List(offsetInBytes, …)`**:原生过来的 Uint8List
// 是带 offset 的视图,偏移是奇数时这行直接抛
// `RangeError: Offset must be a multiple of BYTES_PER_ELEMENT`。
// 实测这条异常在一次会话里抛了 6990 次——通话录音那边早就踩过并改成了
// 按字节搬(见 BesCallRecordingService._writeStereo 的说明),
// 现场录音这条链路一直没同步修。
// 直接按小端手工拼 int16,没有对齐要求。
final count = pcm.lengthInBytes ~/ 2;
if (count == 0) return;
var sum = 0.0;
for (var i = 0; i < count; i++) {
final lo = pcm[i * 2];
final hi = pcm[i * 2 + 1];
var v = (hi << 8) | lo;
if (v >= 0x8000) v -= 0x10000; // 转有符号
final n = v / 32768.0;
sum += n * n;
}
final rms = math.sqrt(sum / count);
level.value = math.sqrt(rms).clamp(0.0, 1.0);
}
Future<void> _closeFile({required bool save}) async {
await _audioSub?.cancel();
_audioSub = null;
final raf = _raf;
_raf = null;
if (raf == null) return;
try {
if (save) {
await raf.setPosition(0);
await raf.writeFrom(_wavHeader(_dataBytes));
}
await raf.close();
} catch (e) {
Logger.e(_tag, '关闭录音文件失败: $e');
}
}
/// 归档进语音记录助手。具体入库逻辑见 [RecordingArchive]——
/// 现场录音 / 通话录音 / 翻译录音都走同一套,不再各抄一份。
Future<void> _archive(String path, int seconds) async {
await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag);
}
Uint8List _wavHeader(int dataLength) {
const sampleRate = 16000, channels = 1, bits = 16;
final byteRate = sampleRate * channels * (bits ~/ 8);
final h = ByteData(44);
void ascii(int off, String s) {
for (var i = 0; i < s.length; i++) {
h.setUint8(off + i, s.codeUnitAt(i));
}
}
ascii(0, 'RIFF');
h.setUint32(4, 36 + dataLength, Endian.little);
ascii(8, 'WAVE');
ascii(12, 'fmt ');
h.setUint32(16, 16, Endian.little);
h.setUint16(20, 1, Endian.little);
h.setUint16(22, channels, Endian.little);
h.setUint32(24, sampleRate, Endian.little);
h.setUint32(28, byteRate, Endian.little);
h.setUint16(32, channels * (bits ~/ 8), Endian.little);
h.setUint16(34, bits, Endian.little);
ascii(36, 'data');
h.setUint32(40, dataLength, Endian.little);
return h.buffer.asUint8List();
}
}

1073
apps/client/lib/data/services/ble_manager.dart

File diff suppressed because it is too large

142
apps/client/lib/data/services/bluetooth_manager.dart

@ -1,142 +0,0 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:classic_bluetooth/classic_bluetooth.dart';
import '../../core/utils/logger.dart';
// 重新导出蓝牙设备类型,让外部可以通过BluetoothManager访问
export 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice;
/// 蓝牙服务 - 管理经典蓝牙服务
class BluetoothManager extends GetxService {
static BluetoothManager get to => Get.find<BluetoothManager>();
// 蓝牙状态
final _bluetoothState = 'unknown'.obs;
String get bluetoothState => _bluetoothState.value;
RxString get bluetoothStateRx => _bluetoothState;
// 当前经典蓝牙设备
final Rx<BluetoothDevice?> currentDeviceRx = Rx<BluetoothDevice?>(null);
BluetoothDevice? get currentDevice => currentDeviceRx.value;
// 订阅
StreamSubscription<BluetoothEvent>? _bluetoothSubscription;
/// 初始化
@override
void onInit() {
super.onInit();
Logger.info('蓝牙服务初始化');
// 设置蓝牙事件监听
_setupBluetoothListener();
// 主动检查当前已连接的设备和蓝牙状态
Future.delayed(Duration.zero, () async {
Logger.info('检查初始蓝牙连接状态');
// 先检查蓝牙是否开启
await isBluetoothEnabled();
// 然后刷新设备连接
await refreshDeviceConnection();
});
}
/// 释放资源
@override
void onClose() {
print("蓝牙服务关闭");
_bluetoothSubscription?.cancel();
super.onClose();
}
/// 设置经典蓝牙事件监听
void _setupBluetoothListener() {
_bluetoothSubscription?.cancel();
try {
_bluetoothSubscription = ClassicBluetooth.events.listen(
(event) {
print("_setupBluetoothListener$event");
if (event.type == 'stateChanged' && event.state != null) {
_bluetoothState.value = event.state!;
Logger.info('蓝牙状态变更: ${event.state}');
// 如果蓝牙关闭,清除设备
if (event.state == 'off') {
currentDeviceRx.value = null;
}
} else if (event.type == 'deviceConnected' && event.device != null) {
// 直接从事件中获取设备信息
final newDevice = event.device!;
currentDeviceRx.value = newDevice;
// 经典蓝牙连接事件:仅记录日志,不再跳转配对页(绑定流程已下线)
Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})');
} else if (event.type == 'deviceDisconnected' &&
event.device != null) {
// 直接从事件中获取设备信息
final disconnectedDevice = event.device!;
Logger.info(
'经典蓝牙设备已断开: ${disconnectedDevice.name} (${disconnectedDevice.address})');
// 如果断开的是当前设备,清除当前设备
if (currentDeviceRx.value?.address == disconnectedDevice.address) {
currentDeviceRx.value = null;
Logger.info('已清除当前设备引用');
}
}
},
onError: (error) {
Logger.error('蓝牙事件流错误: ${error.toString()}');
},
);
} catch (e) {
Logger.error('设置蓝牙事件监听器失败: ${e.toString()}');
}
}
/// 刷新设备连接状态
Future<void> refreshDeviceConnection() async {
try {
// 如果BLE未连接,则检查经典蓝牙连接
final devices = await ClassicBluetooth.getConnectedDevices();
if (devices.isNotEmpty) {
for (var device in devices) {
if (device.isActive) {
// 如果有活动设备,直接使用它
currentDeviceRx.value = device;
Logger.info('已连接经典蓝牙设备: ${device.name} (${device.address})');
return;
}
}
final newDevice = devices.first;
currentDeviceRx.value = newDevice;
Logger.info('已连接经典蓝牙设备: ${newDevice.name} (${newDevice.address})');
return;
}
// 没有任何连接
currentDeviceRx.value = null;
Logger.info('未找到已连接的蓝牙设备');
} catch (e) {
Logger.error('刷新设备连接失败: ${e.toString()}');
}
}
/// 检查蓝牙是否已启用
Future<bool> isBluetoothEnabled() async {
try {
final isEnabled = await ClassicBluetooth.isBluetoothEnabled();
_bluetoothState.value = isEnabled ? 'on' : 'off';
return isEnabled;
} catch (e) {
Logger.error('检查蓝牙状态失败: ${e.toString()}');
return false;
}
}
}

113
apps/client/lib/data/services/face_to_face_ble_service.dart

@ -1,113 +0,0 @@
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import 'bes_bluetooth_service.dart';
/// 面对面翻译时,左右耳分别独立走 BLE 的连接管理。
///
/// 背景:普通模式下耳机是一条经典蓝牙 SPP([BesBluetoothService])统一收发;
/// 面对面翻译要让两只耳朵各自朝向一位说话人——左耳收/放一种语言,
/// 右耳收/放另一种语言——所以连接模式要整体切到 BLE,
/// **左耳一条 BLE、右耳一条 BLE,互相独立**。
///
/// 目前状态:**框架先搭好,具体连接与收发留空**。
/// 固件那边的服务/特征 UUID、左右耳的广播名与配对流程都还没给,
/// 等命令确定后在 [_connectSide] / [_disconnectSide] / [sendToSide] 里补实现即可,
/// 上层(面对面翻译页与控制器)不需要再改。
///
/// 这样安排的原因:面对面翻译的开关时机、UI 状态、与 SPP 的互斥关系
/// 是现在就能定下来的,先把这层接口固化,后面补底层不会牵动业务逻辑。
enum EarSide { left, right }
class FaceToFaceBleService extends GetxService {
static const String _tag = 'FaceToFaceBleService';
static FaceToFaceBleService get to => Get.find();
/// 左右耳各自的连接状态:0=未连接 1=连接中 2=已连接
final RxInt leftStatus = 0.obs;
final RxInt rightStatus = 0.obs;
/// 当前是否处于面对面翻译的 BLE 模式
final RxBool isBleMode = false.obs;
bool get leftConnected => leftStatus.value == 2;
bool get rightConnected => rightStatus.value == 2;
/// 进入面对面翻译:通知耳机切模式(`AA 66`),随后把左右耳各自拉起 BLE。
///
/// 返回是否成功进入模式。左右耳 BLE 尚未实现,当前只要 `AA 66` 发出去就算成功,
/// 不因为 BLE 没连上而阻断——否则功能在固件就绪前完全不可用。
Future<bool> enterFaceToFaceMode() async {
if (isBleMode.value) return true;
try {
if (Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.isConnected) {
await BesBluetoothService.to.startFaceToFace(); // AA 66
Logger.i(_tag, '已下发 AA 66,耳机进入面对面翻译模式');
} else {
Logger.w(_tag, '耳机未连接,跳过 AA 66');
}
isBleMode.value = true;
// 左右耳分别建立独立 BLE 连接(待实现)
await _connectSide(EarSide.left);
await _connectSide(EarSide.right);
return true;
} catch (e) {
Logger.e(_tag, '进入面对面翻译模式失败: $e');
return false;
}
}
/// 退出面对面翻译:断开左右耳 BLE,并通知耳机回到常规模式(`AA 67`)
Future<void> exitFaceToFaceMode() async {
if (!isBleMode.value) return;
try {
await _disconnectSide(EarSide.left);
await _disconnectSide(EarSide.right);
if (Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.isConnected) {
await BesBluetoothService.to.stopFaceToFace(); // AA 67
Logger.i(_tag, '已下发 AA 67,耳机退出面对面翻译模式');
}
} catch (e) {
Logger.e(_tag, '退出面对面翻译模式失败: $e');
} finally {
isBleMode.value = false;
leftStatus.value = 0;
rightStatus.value = 0;
}
}
/// 建立某一侧耳机的独立 BLE 连接。
///
/// TODO(固件): 命令与 UUID 确定后在此实现——
/// 扫描该侧广播 → 连接 → 发现服务 → 订阅通知 → 置 status=2。
Future<void> _connectSide(EarSide side) async {
final status = side == EarSide.left ? leftStatus : rightStatus;
status.value = 1;
Logger.i(_tag, '${_sideName(side)} BLE 连接:待固件命令确定后实现');
// 尚未实现,保持「连接中」而不是假装已连接,避免上层误判为可用
status.value = 0;
}
/// 断开某一侧耳机的 BLE 连接。
///
/// TODO(固件): 与 [_connectSide] 配套实现。
Future<void> _disconnectSide(EarSide side) async {
final status = side == EarSide.left ? leftStatus : rightStatus;
if (status.value == 0) return;
Logger.i(_tag, '${_sideName(side)} BLE 断开:待实现');
status.value = 0;
}
/// 向指定一侧下发数据(译文音频 / 控制指令)。
///
/// TODO(固件): 左右耳各自的写特征确定后在此实现。
/// 现在直接返回 false,调用方据此判断「该侧尚不可用」。
Future<bool> sendToSide(EarSide side, List<int> bytes) async {
Logger.d(_tag, '${_sideName(side)} 下发 ${bytes.length} 字节:待实现');
return false;
}
String _sideName(EarSide side) => side == EarSide.left ? '左耳' : '右耳';
}

309
apps/client/lib/data/services/holder_recording_service.dart

@ -1,309 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../core/utils/logger.dart';
import 'holder_device_service.dart';
import 'recording_archive.dart';
/// 蓝讯支架录音的全局采集与归档服务。
///
/// 之前这套逻辑挂在现场录音页的控制器上,页面一关监听就没了——用户在支架上
/// 按录音键时 App 完全没反应。现在提到常驻服务里:不管当前在哪个页面、甚至
/// 页面没打开,只要设备端起停录音,这里都会跟着采集/落盘/归档。
///
/// 职责:
/// 1. 订阅 [HolderDeviceService.recording],设备端开始/结束录音时自动起停
/// 2. 录制期间把 SDK 推的 PCM 帧写成本地 WAV(16kHz/16bit/单声道)
/// 3. 结束后归档进「语音记录助手」,由 [MeetingUploadService] 按既有逻辑上云
class HolderRecordingService extends GetxService {
static const String _tag = 'HolderRecordingService';
static HolderRecordingService get to => Get.find();
/// 当前是否正在录(由设备端状态驱动)
final RxBool isRecording = false.obs;
/// 已录毫秒数
final RxInt elapsedMs = 0.obs;
/// 当前电平 0~1,供波形使用
final RxDouble level = 0.0.obs;
/// 波形采样序列(固定 [sampleIntervalMs] 一格)
final RxList<double> waveform = <double>[].obs;
static const int sampleIntervalMs = 100;
static const int _maxSamples = 600;
Worker? _stateWorker;
StreamSubscription<EchoMeetAudioFrame>? _audioSub;
RandomAccessFile? _raf;
int _dataBytes = 0;
String? _path;
Timer? _tick;
Timer? _sampleTimer;
final Stopwatch _stopwatch = Stopwatch();
@override
void onInit() {
super.onInit();
_bind();
}
@override
void onClose() {
_stateWorker?.dispose();
_audioSub?.cancel();
_tick?.cancel();
_sampleTimer?.cancel();
super.onClose();
}
void _bind() {
if (!Get.isRegistered<HolderDeviceService>()) return;
final holder = Get.find<HolderDeviceService>();
_stateWorker = ever<EchoMeetRecordingState>(holder.recording, (st) async {
Logger.i(_tag,
'设备录音状态上报: isRecording=${st.isRecording} timerStatus=${st.timerStatus} '
'seconds=${st.seconds} 本地=${isRecording.value} 忙=$_busy');
if (_busy) {
Logger.w(_tag, '本地正在起停中,忽略这次上报');
return;
}
if (st.isRecording && !isRecording.value) {
Logger.i(_tag, '设备端开始录音,启动本地采集');
await start();
} else if (!st.isRecording && isRecording.value) {
Logger.i(_tag, '设备端结束录音,落盘并归档');
await stop();
}
});
}
/// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令
/// (App 主动发起录音的场景)。
/// 起停过程中的互斥标志:设备上报和 UI 操作可能同时进来,
/// 不加锁会出现「刚建好文件又被另一路关掉」的竞态。
bool _busy = false;
Future<bool> start({bool sendCommand = false}) async {
if (isRecording.value || _busy) return isRecording.value;
_busy = true;
try {
return await _start(sendCommand);
} finally {
_busy = false;
}
}
Future<bool> _start(bool sendCommand) async {
if (!Get.isRegistered<HolderDeviceService>()) return false;
final holder = Get.find<HolderDeviceService>();
if (!holder.isConnected) return false;
try {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/field_recordings');
if (!await dir.exists()) await dir.create(recursive: true);
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
_path = '${dir.path}/holder_$ts.wav';
_raf = await File(_path!).open(mode: FileMode.write);
await _raf!.writeFrom(_wavHeader(0));
_dataBytes = 0;
_audioSub?.cancel();
_audioSub = holder.audioFrames.listen(_onFrame);
// 顺序很关键:**先切录音状态,再开编码器**。
//
// toggleRecording 是盲切:原生 BleRecord 若当前是 RUNNING,切过去会走
// pauseRecord() → BleService.closeCodec(),把刚开的编码器关掉。实测就是
// openEncoder 后 5ms 立刻跟了一条 0x05/0x00(CODEC_CONTROL_CLOSE)。
// 把 openEncoder 放到最后,保证不论状态怎么切,最终编码器都是开着的。
if (sendCommand && !holder.deviceIsRecording) {
final ok = await holder.startRecording();
if (!ok) {
await _closeFile(save: false);
return false;
}
} else if (sendCommand) {
Logger.i(_tag, '设备已在录音中,跳过状态切换,仅接管本地采集');
}
// 【必须】打开设备编码器,否则设备不往手机推 Opus 流。
// 协议帧 0xAA 0x05 0x02 0xB1 0x02 [CRC]。设备按键发起的录音只会让原生
// 切本地录音态,不会自动开编码器,所以这里无条件补一条。
final encoderOk = await holder.openEncoder();
if (!encoderOk) {
Logger.w(_tag, 'openEncoder 返回 false,可能收不到音频流');
}
isRecording.value = true;
elapsedMs.value = 0;
waveform.clear();
_stopwatch
..reset()
..start();
_tick?.cancel();
_tick = Timer.periodic(const Duration(milliseconds: 50),
(_) => elapsedMs.value = _stopwatch.elapsedMilliseconds);
_sampleTimer?.cancel();
_sampleTimer = Timer.periodic(
const Duration(milliseconds: sampleIntervalMs),
(_) => _sample(),
);
Logger.i(_tag, '开始录制: $_path');
return true;
} catch (e) {
Logger.e(_tag, '启动本地采集失败: $e');
await _closeFile(save: false);
return false;
}
}
/// 停止。[sendCommand] = true 时同时下发设备停止指令。
Future<void> stop({bool sendCommand = false}) async {
if (!isRecording.value || _busy) return;
_busy = true;
try {
await _stop(sendCommand);
} finally {
_busy = false;
}
}
Future<void> _stop(bool sendCommand) async {
_tick?.cancel();
_sampleTimer?.cancel();
_stopwatch.stop();
final int seconds = elapsedMs.value ~/ 1000;
final String? path = _path;
if (sendCommand &&
Get.isRegistered<HolderDeviceService>() &&
Get.find<HolderDeviceService>().deviceIsRecording) {
try {
await Get.find<HolderDeviceService>().stopRecording(
fileName: path == null ? null : p.basenameWithoutExtension(path));
} catch (e) {
Logger.e(_tag, '下发停止指令失败: $e');
}
}
await _closeFile(save: true);
isRecording.value = false;
elapsedMs.value = 0;
level.value = 0;
waveform.clear();
if (path != null && _dataBytes > 0) {
// 归档会把文件从 field_recordings 挪到 LocalAudio,
// 所以现场录音页的列表里不会再留副本——录音成品统一由语音记录助手管理。
await _archive(path, seconds);
} else {
// 没收到音频的空文件(只有 44 字节 WAV 头)直接删掉,
// 否则会在现场录音列表里堆一堆 00:00 的无效记录。
Logger.w(_tag, '本次没有收到音频数据,删除空文件');
if (path != null) {
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (e) {
Logger.e(_tag, '删除空录音文件失败: $e');
}
}
}
_dataBytes = 0;
_path = null;
}
void _onFrame(EchoMeetAudioFrame frame) async {
final raf = _raf;
if (raf == null || frame.pcm.isEmpty) return;
try {
await raf.writeFrom(frame.pcm);
_dataBytes += frame.pcm.length;
_updateLevel(frame.pcm);
} catch (e) {
Logger.e(_tag, '写入 PCM 失败: $e');
}
}
void _updateLevel(Uint8List pcm) {
if (pcm.lengthInBytes < 2) return;
final s = pcm.buffer.asInt16List(pcm.offsetInBytes, pcm.lengthInBytes ~/ 2);
var sum = 0.0;
for (final v in s) {
final n = v / 32768.0;
sum += n * n;
}
final rms = math.sqrt(sum / s.length);
level.value = math.sqrt(rms).clamp(0.0, 1.0);
}
void _sample() {
final list = waveform.toList();
list.add(level.value);
if (list.length > _maxSamples) {
list.removeRange(0, list.length - _maxSamples);
}
waveform.assignAll(list);
}
Future<void> _closeFile({required bool save}) async {
await _audioSub?.cancel();
_audioSub = null;
final raf = _raf;
_raf = null;
if (raf == null) return;
try {
if (save) {
await raf.setPosition(0);
await raf.writeFrom(_wavHeader(_dataBytes));
}
await raf.close();
} catch (e) {
Logger.e(_tag, '关闭录音文件失败: $e');
}
}
/// 归档进语音记录助手。具体入库逻辑见 [RecordingArchive]——
/// 现场录音 / 通话录音 / 翻译录音都走同一套,不再各抄一份。
Future<void> _archive(String path, int seconds) async {
await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag);
}
Uint8List _wavHeader(int dataLength) {
const sampleRate = 16000, channels = 1, bits = 16;
final byteRate = sampleRate * channels * (bits ~/ 8);
final h = ByteData(44);
void ascii(int off, String s) {
for (var i = 0; i < s.length; i++) {
h.setUint8(off + i, s.codeUnitAt(i));
}
}
ascii(0, 'RIFF');
h.setUint32(4, 36 + dataLength, Endian.little);
ascii(8, 'WAVE');
ascii(12, 'fmt ');
h.setUint32(16, 16, Endian.little);
h.setUint16(20, 1, Endian.little);
h.setUint16(22, channels, Endian.little);
h.setUint32(24, sampleRate, Endian.little);
h.setUint32(28, byteRate, Endian.little);
h.setUint16(32, channels * (bits ~/ 8), Endian.little);
h.setUint16(34, bits, Endian.little);
ascii(36, 'data');
h.setUint32(40, dataLength, Endian.little);
return h.buffer.asUint8List();
}
}

95
apps/client/lib/data/services/kouzi_ai_service.dart

@ -1,95 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../core/utils/logger.dart';
import 'ai_service.dart';
class KouziAIService implements AiService {
final String apiKey =
'pat_QGDh6UGm3PaeL9GRS95JxrJ9aoU92ehMLQQ2NCre1TeBC5YeTcjpDmlz3efsJ8lh';
final String modelId = '7481292724306657316';
@override
Future<String> sendMessage({
required List<Map<String, String>> messages,
String? systemPrompt,
String? userProperties,
}) async {
return '';
}
@override
Stream<String> sendMessageStream({
required List<Map<String, String>> messages,
String? systemPrompt,
String? userProperties,
}) async* {
final client = http.Client();
final url = Uri.parse('https://api.coze.cn/v3/chat');
final headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer $apiKey',
};
final requestBody = {
"bot_id": modelId,
"user_id": "3422",
"additional_messages": [
{
"content": messages.last['content'],
"content_type": "text",
"role": "user",
"type": "question",
},
],
"shortcut_command": {"command_id": "0"},
"stream": true,
};
try {
final request = http.Request('POST', url)
..headers.addAll(headers)
..body = jsonEncode(requestBody);
final response = await client.send(request);
final regex = RegExp(r'(?<=[。!?;:.:;?!\n])');
String allContent = '';
//流式处理响应数据
await for (final chunk in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (chunk.isEmpty) continue;
if (chunk.startsWith('data:')) {
final jsonStr = chunk.substring(5).trim();
if (jsonStr == '[DONE]') {
break;
}
try {
final data = jsonDecode(jsonStr);
if (data['type'] == 'answer') {
String content = data['content'] ?? '';
if (content.isNotEmpty && allContent != content) {
allContent += content;
List splitText =
content.split(regex).where((s) => s.isNotEmpty).toList();
for (var element in splitText) {
yield element;
}
}
}
} catch (e) {
Logger.error('JSON解析错误: ${e.toString()}');
}
}
}
} catch (e) {
Logger.error('请求异常: ${e.toString()}');
} finally {
client.close();
}
}
}

301
apps/client/lib/data/services/open_ai_service_adapter.dart

@ -1,301 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import '../../data/models/appconfig.dart';
import '../../data/models/appconfig_model.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart';
import 'package:open_ai_service/open_ai_service.dart';
import 'package:get/get.dart';
import 'ai_service.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
/// 定义流事件类型,用于区分不同类型的事件
enum StreamEventType { token, functionCall, complete, error }
/// 流事件包装类
class StreamEvent {
final StreamEventType type;
final String? content;
final Map? meta;
StreamEvent(this.type, {this.content, this.meta});
}
/// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件
class OpenAIServiceAdapter implements AiService {
final OpenAIService _openAIService = OpenAIService();
StreamSubscription<OpenAIEvent>? _eventSubscription;
final StreamController<StreamEvent> _tokenStreamController =
StreamController<StreamEvent>.broadcast();
final GetStorage _storage = GetStorage();
/// 构造函数
OpenAIServiceAdapter() {
printInfo(info: '创建OpenAIServiceAdapter实例');
_setupEventListener();
}
/// 设置事件监听器
void _setupEventListener() {
try {
// 首先确保访问eventStream以初始化底层事件通道
_openAIService.eventStream;
// 设置事件处理
_eventSubscription = _openAIService.eventStream.listen(
(event) {
try {
switch (event.type) {
case OpenAIEventType.token:
if (event.content is String) {
_tokenStreamController.add(StreamEvent(StreamEventType.token,
content: event.content as String));
} else {
printInfo(info: '收到非字符串类型的token: ${event.content}');
}
break;
case OpenAIEventType.complete:
_tokenStreamController
.add(StreamEvent(StreamEventType.complete));
break;
case OpenAIEventType.error:
_tokenStreamController.add(StreamEvent(StreamEventType.error,
content: '未知错误: ${event.content}'));
break;
case OpenAIEventType.functionCall:
printInfo(
info:
'接收到 functionCall content: ${event.content} meta: ${event.meta}');
_tokenStreamController.add(StreamEvent(
StreamEventType.functionCall,
meta: event.meta as Map<String, dynamic>?));
// _tokenStreamController.add(StreamEvent(
// StreamEventType.error,
// content: '收到函数调用,该流仅支持文本响应'
// ));
break;
}
} catch (e) {
printError(info: '处理事件出错: $e');
_tokenStreamController
.add(StreamEvent(StreamEventType.error, content: '处理事件失败: $e'));
}
},
onError: (error) {
printError(info: '事件流错误: $error');
_tokenStreamController.add(
StreamEvent(StreamEventType.error, content: '事件流错误: $error'));
},
onDone: () {
printInfo(info: '事件流已关闭');
},
);
} catch (e) {
printError(info: '设置事件监听器失败: $e');
}
}
/// 初始化OpenAI服务
Future<bool> initialize() async {
try {
// 从.env文件中读取配置
// final apiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
// final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? '';
// final model = dotenv.env['OPENAI_MODEL'] ?? '';
// final _env = _storage.read("ENV") as Map<String, String>;
final apiKey = AppConfig.env('OPENAI_API_KEY') ?? '';
final baseUrl = AppConfig.env('OPENAI_BASE_URL') ?? '';
final model = AppConfig.env('OPENAI_MODEL') ?? '';
printInfo(info: '从.env读取OpenAI配置');
printInfo(info: '基础URL: $baseUrl');
printInfo(info: '模型名称: $model');
if (apiKey.isEmpty) {
printError(info: '错误: OpenAI API密钥未配置,请在.env文件中设置OPENAI_API_KEY');
return false;
}
final _mcps = _storage.read("MCPS") as Map<String, DBMCPServer>;
// 读取.mcp.json文件
String mcpConfig = jsonEncode(_mcps);
// try {
// // 从Flutter资源包中加载.mcp.json
// mcpConfig = await rootBundle.loadString('.mcp.json');
// printInfo(info: '成功从资源包加载.mcp.json配置文件');
// // 验证JSON格式
// final jsonData = jsonDecode(mcpConfig);
// if (jsonData is Map && jsonData.containsKey('mcpServers')) {
// printInfo(info: '解析到有效的mcpServers配置');
// } else {
// printInfo(info: '.mcp.json内容格式不正确,期望包含mcpServers字段');
// }
// } catch (e) {
// printError(info: '加载.mcp.json资源文件时出错: $e');
// }
// 初始化OpenAI服务
final result = await _openAIService.initialize(
apiKey: apiKey,
baseUrl: baseUrl,
model: model,
mcpServer: mcpConfig,
);
if (result) {
printInfo(info: 'OpenAI服务初始化成功');
} else {
printError(info: 'OpenAI服务初始化失败');
}
return result;
} catch (e) {
printError(info: 'OpenAI服务初始化异常: $e');
return false;
}
}
/// 发送消息并获取回复
@override
Future<String> sendMessage({
required List<Map<String, String>> messages,
String? systemPrompt,
String? userProperties,
}) async {
try {
// 在方法内直接转换
final convertedMessages =
messages.map((m) => Map<String, dynamic>.from(m)).toList();
// 发送消息并获取回复
final response = await _openAIService.sendMessage(
messages: convertedMessages,
systemPrompt: systemPrompt,
userProperties: userProperties,
);
return response;
} catch (e) {
printError(info: 'OpenAI发送消息失败: $e');
throw '发送消息失败: $e';
}
}
/// 发送消息并获取流式回复
@override
Stream sendMessageStream({
required List<Map<String, String>> messages,
String? systemPrompt, //提示词
String? userProperties, //用户属性
}) async* {
try {
// 在方法内直接转换
final convertedMessages =
messages.map((m) => Map<String, dynamic>.from(m)).toList();
// 创建用于接收token的控制器
final localController = StreamController();
// 添加从广播流到本地流的订阅
final subscription = _tokenStreamController.stream.listen((streamEvent) {
switch (streamEvent.type) {
case StreamEventType.token:
if (streamEvent.content != null && !localController.isClosed) {
localController.add(streamEvent.content!);
}
break;
case StreamEventType.functionCall:
if (streamEvent.meta != null && !localController.isClosed) {
localController.add(streamEvent.meta!);
}
break;
case StreamEventType.complete:
if (!localController.isClosed) {
localController.close();
}
break;
case StreamEventType.error:
if (!localController.isClosed) {
printError(info: '令牌流错误: ${streamEvent.content}');
localController.addError(streamEvent.content ?? '未知错误');
localController.close();
}
break;
}
});
// 当本地控制器关闭时,取消订阅
localController.onCancel = () {
subscription.cancel();
};
// 启动流式消息请求
bool started = false;
try {
started = await _openAIService.sendMessageStream(
messages: convertedMessages,
systemPrompt: systemPrompt,
userProperties: userProperties,
);
} catch (e) {
printError(info: '启动消息流失败: $e');
if (!localController.isClosed) {
localController.addError('启动消息流失败: $e');
localController.close();
}
throw '启动消息流失败: $e';
}
if (!started) {
printError(info: '无法启动消息流');
if (!localController.isClosed) {
localController.addError('无法启动消息流');
localController.close();
}
throw '无法启动消息流';
}
// 通过yield*将controller的流转发
yield* localController.stream;
} catch (e) {
printError(info: 'OpenAI流式消息处理失败: $e');
throw '流式消息处理失败: $e';
}
}
/// 注册函数
Future<bool> registerFunction(
String name, String description, Map<String, dynamic> parameters) async {
try {
final result = await _openAIService.registerFunction(
name: name,
description: description,
parameters: parameters,
);
if (result) {
printInfo(info: '函数 "$name" 注册成功');
} else {
printError(info: '函数 "$name" 注册失败');
}
return result;
} catch (e) {
printError(info: '注册函数失败: $e');
return false;
}
}
/// 释放资源
void dispose() {
try {
_eventSubscription?.cancel();
_tokenStreamController.close();
printInfo(info: 'OpenAIServiceAdapter资源已释放');
} catch (e) {
printError(info: '释放资源时出错: $e');
}
}
}

154
apps/client/lib/data/utils/jieli_adv_parser.dart

@ -1,154 +0,0 @@
import 'dart:io';
import 'package:device_jieli/device_jieli.dart';
import '../../core/utils/logger.dart';
/// 杰理设备广播包解析结果:从扫描到的 [JieliDevice.rawAdv] 中提取出
/// 服务端绑定接口需要的 PID / MAC / 设备授权码 (code)。
class JieliBindInfo {
/// 产品 ID(小端字节解析)
final int pid;
/// 设备真实 MAC(XOR 0x5A 解码后),格式 "AA:BB:CC:DD:EE:FF"
final String mac;
/// 设备授权码 (license / SN),hex 大写
final String code;
/// 解析得到的 PID 是否与扫描结果上的 BLE/EDR 地址一致
/// (仅用于日志,不强制要求一致)
final bool macMatchesAddress;
const JieliBindInfo({
required this.pid,
required this.mac,
required this.code,
required this.macMatchesAddress,
});
@override
String toString() =>
'JieliBindInfo(pid=0x${pid.toRadixString(16).toUpperCase()}, mac=$mac, code=$code, match=$macMatchesAddress)';
}
/// 杰理 BLE 广播解析。
///
/// 字节布局(与旧版一致,基于扫到的 raw scanRecord 字节):
/// Android:
/// - bytes[6..7] → PID
/// - bytes[9..14] → MAC(每字节 XOR 0x5A)
/// - bytes[15..24] → 授权码 code
/// iOS:
/// - bytes[4..5] → PID
/// - bytes[7..12] → MAC(每字节 XOR 0x5A)
/// - bytes[13..22] → 授权码 code
class JieliAdvParser {
static const String _tag = 'JieliAdvParser';
/// 用 [device.rawAdv] 直接解析。若 raw 数据不完整或 device 未携带广播包,返回 null。
static JieliBindInfo? parse(JieliDevice device) {
final raw = device.rawAdv;
if (raw == null || raw.isEmpty) {
Logger.w(_tag, '设备 ${device.address} 没有 rawAdv,无法解析绑定信息');
return null;
}
return parseHex(raw, deviceAddress: device.address);
}
/// 直接对 hex 字符串解析,便于测试。
static JieliBindInfo? parseHex(String hex, {String? deviceAddress}) {
final bytes = _hexToBytes(hex);
if (bytes == null) {
Logger.w(_tag, 'rawAdv 不是合法 hex: $hex');
return null;
}
final int pidStart;
final int pidEnd;
final int macStart;
final int macEnd;
final int codeStart;
final int codeEnd;
if (Platform.isIOS) {
pidStart = 4;
pidEnd = 5;
macStart = 7;
macEnd = 12;
codeStart = 13;
codeEnd = 22;
} else {
// Android 默认;其他平台沿用 Android 偏移。
pidStart = 6;
pidEnd = 7;
macStart = 9;
macEnd = 14;
codeStart = 15;
codeEnd = 24;
}
if (bytes.length <= codeEnd) {
Logger.w(_tag,
'rawAdv 长度不足 ${codeEnd + 1} 字节(实际 ${bytes.length}),跳过解析');
return null;
}
final pid = _bytesToIntBigEndian(bytes, pidStart, pidEnd);
final mac = _decodeMac(bytes, macStart, macEnd);
final code = _bytesToHexReversed(bytes, codeStart, codeEnd);
bool match = false;
if (deviceAddress != null && deviceAddress.isNotEmpty) {
final a = deviceAddress.replaceAll(':', '').toUpperCase();
final b = mac.replaceAll(':', '').toUpperCase();
match = a == b;
}
final info = JieliBindInfo(
pid: pid,
mac: mac,
code: code,
macMatchesAddress: match,
);
Logger.i(_tag, '解析广播: $info (deviceAddress=$deviceAddress)');
return info;
}
static List<int>? _hexToBytes(String hex) {
final s = hex.startsWith('0x') || hex.startsWith('0X') ? hex.substring(2) : hex;
if (s.length.isOdd) return null;
final out = <int>[];
for (int i = 0; i < s.length; i += 2) {
final v = int.tryParse(s.substring(i, i + 2), radix: 16);
if (v == null) return null;
out.add(v);
}
return out;
}
static int _bytesToIntBigEndian(List<int> bytes, int start, int end) {
int result = 0;
for (int i = start; i <= end; i++) {
result = (result << 8) | (bytes[i] & 0xFF);
}
return result;
}
static String _bytesToHexReversed(List<int> bytes, int start, int end) {
final slice = bytes.sublist(start, end + 1);
return slice
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
static String _decodeMac(List<int> bytes, int start, int end) {
final out = <String>[];
for (int i = start; i <= end; i++) {
final decoded = bytes[i] ^ 0x5A;
out.add(decoded.toRadixString(16).padLeft(2, '0').toUpperCase());
}
return out.join(':');
}
}

0
apps/client/lib/data/services/bes_bluetooth_service.dart → apps/client/lib/devices/bes/bes_bluetooth_service.dart

0
apps/client/lib/data/services/bes_device_auth.dart → apps/client/lib/devices/bes/bes_device_auth.dart

0
apps/client/lib/data/services/holder_device_service.dart → apps/client/lib/devices/holder/holder_device_service.dart

11
apps/client/lib/modules/agent/bindings/agent_binding.dart

@ -1,11 +0,0 @@
import 'package:get/get.dart';
import '../controllers/agent_controller.dart';
import '../controllers/message_action_controller.dart';
class AgentBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<AgentController>(() => AgentController());
Get.lazyPut<MessageActionController>(() => MessageActionController());
}
}

3014
apps/client/lib/modules/agent/controllers/agent_controller.dart

File diff suppressed because it is too large

412
apps/client/lib/modules/agent/controllers/message_action_controller.dart

@ -1,412 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:haptic_feedback/haptic_feedback.dart';
import '../../../core/theme/app_colors.dart';
import '../../../data/models/message_model.dart';
import '../../../data/services/network/api.dart';
/// 消息操作控制器
/// 负责处理消息长按、复制、举报等操作
class MessageActionController extends GetxController {
// 当前举报的消息
final reportingMessage = Rxn<Message>();
// 选择的举报原因
List<String> selectedReasons = [];
// 举报描述
final reportDescription = ''.obs;
/// 处理消息长按事件
void onLongPressMessage(
BuildContext context,
bool isDarkMode,
LongPressStartDetails details,
Message message,
) {
// 触发触觉反馈
Haptics.vibrate(HapticsType.success);
// 计算弹出菜单位置
RenderBox? renderBox =
Overlay.of(context).context.findRenderObject() as RenderBox;
final RelativeRect position = RelativeRect.fromRect(
Rect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx + 110, // 菜单显示位置X轴坐标
details.globalPosition.dy - 40, // 菜单显示位置Y轴坐标
),
Offset.zero & renderBox.size,
);
// 显示弹出菜单
showMenu(
context: context,
position: position,
color: isDarkMode ? AppColors.cardBackground : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
items: _buildMenuItems(context, isDarkMode, message),
);
}
/// 构建弹出菜单项
List<PopupMenuEntry> _buildMenuItems(
BuildContext context,
bool isDarkMode,
Message message,
) {
final List<PopupMenuEntry> items = [
// 复制选项
_buildPopupMenuItem(
Icon(
Icons.copy,
size: 20.w,
color: isDarkMode ? Colors.white : Colors.black87,
),
'copy'.tr, // 复制
isDarkMode ? Colors.white : Colors.black87,
() => _copyMessage(message),
),
];
// 如果不是用户消息,添加举报选项
if (!message.isUser) {
items.addAll([
// 分隔线
PopupMenuItem(
height: 1,
enabled: false,
child: Divider(
height: 1,
thickness: 0.8,
indent: 0,
endIndent: 0,
color: isDarkMode ? Colors.grey[700] : Colors.grey[300],
),
),
// 举报选项
_buildPopupMenuItem(
Icon(
Icons.report,
size: 20.w,
color: isDarkMode ? Colors.red[700] : Colors.red,
),
'report'.tr, //举报
isDarkMode ? Colors.red[700]! : Colors.red,
() => _showReportForm(context, isDarkMode, message),
),
]);
}
return items;
}
/// 构建弹出菜单项
PopupMenuEntry _buildPopupMenuItem(
Icon icon,
String title,
Color color,
Function() onTap,
) {
return PopupMenuItem(
onTap: onTap,
child: SizedBox(
width: 140.w,
child: Row(
children: [
icon,
8.horizontalSpace,
Text(
title,
style: TextStyle(
color: color,
fontSize: 14.sp,
),
),
],
),
),
);
}
/// 复制消息内容
void _copyMessage(Message message) {
Clipboard.setData(ClipboardData(text: message.text)).then((_) {
Get.snackbar(
'success'.tr,
'copySuccess'.tr, // 复制成功
backgroundColor: Colors.blue.withOpacity(0.8),
colorText: Colors.white,
);
});
}
/// 显示举报表单
void _showReportForm(BuildContext context, bool isDarkMode, Message message) {
// 初始化举报状态
initReport(message);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
builder: (BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: _ReportForm(isDarkMode: isDarkMode),
);
},
);
}
/// 初始化举报状态
void initReport(Message message) {
reportingMessage.value = message;
selectedReasons.clear();
reportDescription.value = '';
}
/// 切换举报原因选择状态
void toggleReason(String reason) {
if (selectedReasons.contains(reason)) {
selectedReasons.remove(reason);
} else {
selectedReasons.add(reason);
}
update();
}
/// 更新举报描述
void updateDescription(String text) {
reportDescription.value = text;
}
/// 提交举报
Future<void> submitReport() async {
if (reportingMessage.value == null) return;
// 构建举报数据
final reportData = {
'describe': reportDescription.value,
'message': [],
'reportContext': reportingMessage.value!.text,
'reporttype': selectedReasons.toString(),
};
try {
// 调用API提交举报
var response = await Api.reportAiContent(reportData);
if (response != null) {
print('提交举报响应: $response');
}
// 提交成功后重置状态
selectedReasons.clear();
reportDescription.value = '';
reportingMessage.value = null;
// 关闭举报表单
Navigator.of(Get.context!).pop();
// 显示成功通知
Get.snackbar(
'submitSuccess'.tr, // 提交成功
'reportSubmitted'.tr, // 举报已提交,我们会尽快处理
backgroundColor: Colors.green.withOpacity(0.8),
colorText: Colors.white,
duration: const Duration(seconds: 2),
);
} catch (e) {
// 显示错误通知
Get.snackbar(
'submitFailed'.tr, // 提交失败
'networkError'.tr, // 网络错误,请稍后重试
backgroundColor: Colors.red.withOpacity(0.8),
colorText: Colors.white,
duration: const Duration(seconds: 2),
);
}
}
}
/// 举报表单组件
class _ReportForm extends StatelessWidget {
final bool isDarkMode;
final MessageActionController controller =
Get.find<MessageActionController>();
_ReportForm({required this.isDarkMode});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16.w),
child: SingleChildScrollView(
child: GetBuilder<MessageActionController>(
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 标题栏
Stack(
alignment: Alignment.center,
children: [
Center(
child: Text(
'report'.tr, // 举报
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Positioned(
right: 0,
child: IconButton(
icon: Icon(
Icons.close,
color: isDarkMode ? Colors.grey[400] : Colors.black54,
),
onPressed: () => Navigator.pop(context),
),
),
],
),
// 分隔线
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w),
margin: EdgeInsets.only(bottom: 10.w),
child: Divider(
height: 1,
color: Colors.grey[300],
),
),
// 举报原因选择
GetBuilder<MessageActionController>(
builder: (controller) {
return Wrap(
spacing: 8.w,
runSpacing: 8.h,
children: [
_buildReportChip(
'harmfulUnsafe'.tr, isDarkMode, controller), // 有害/不安全
_buildReportChip('pornographicVulgar'.tr, isDarkMode,
controller), // 色情低俗
_buildReportChip('misleadingAttack'.tr, isDarkMode,
controller), // 误导攻击
_buildReportChip(
'illegalCriminal'.tr, isDarkMode, controller), // 违法犯罪
_buildReportChip(
'privacyRelated'.tr, isDarkMode, controller), // 隐私相关
_buildReportChip('copyrightViolation'.tr, isDarkMode,
controller), // 侵犯我的著作权、名誉权等
_buildReportChip(
'other'.tr, isDarkMode, controller), // 其他
],
);
},
),
SizedBox(height: 16.h),
// 描述输入框
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
borderRadius: BorderRadius.circular(8.r),
),
child: TextField(
maxLines: 3,
onChanged: controller.updateDescription,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText:
'reportDescriptionHint'.tr, // 我们想知道你举报的原因,你可以描述你遇到的问题。
hintStyle: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
fontSize: 14.sp,
),
border: InputBorder.none,
),
),
),
SizedBox(height: 14.h),
// 提交按钮
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
child: Text('submit'.tr), // 提交
onPressed: () => controller.submitReport(),
),
SizedBox(height: 8.h),
],
),
),
),
);
}
/// 构建举报原因芯片
Widget _buildReportChip(
String label,
bool isDarkMode,
MessageActionController controller,
) {
final isSelected = controller.selectedReasons.contains(label);
return GestureDetector(
onTap: () => controller.toggleReason(label),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
color: isSelected
? Colors.red
: (isDarkMode ? Colors.grey[800] : Colors.grey[200]),
borderRadius: BorderRadius.circular(16.r),
border: Border.all(
color: isSelected
? Colors.red[700]!
: (isDarkMode ? Colors.grey[700]! : Colors.grey[300]!),
width: isSelected ? 1.0 : 0.5,
),
),
child: Text(
label,
style: TextStyle(
color: isSelected
? Colors.white
: (isDarkMode ? Colors.white : Colors.black87),
fontSize: 14.sp,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
}

915
apps/client/lib/modules/agent/views/agent_view.dart

@ -1,915 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_intro/flutter_intro.dart';
import 'package:get/get.dart';
import '../../../core/widgets/intro_overlay_builder.dart';
import '../../../data/models/channel_app_model.dart';
import '../../../data/services/version_update_service.dart';
import '../controllers/agent_controller.dart';
import '../controllers/message_action_controller.dart';
import 'message_bubble.dart';
import '../widgets/sound_bar_widget.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class AgentView extends StatefulWidget {
const AgentView({Key? key}) : super(key: key);
@override
State<AgentView> createState() => _AgentViewState();
}
class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
late AgentController controller;
late MessageActionController messageActionController;
double _keyboardHeight = 0;
void _showTtsSpeedDialog() {
final primaryColor = Theme.of(context).primaryColor;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final RxInt tempRate = controller.ttsRatePercent.value.obs;
Get.dialog(
AlertDialog(
backgroundColor: isDarkMode ? Colors.grey[850] : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
),
title: Text(
'ttsSpeed'.tr, // 语速
style: TextStyle(fontSize: 16.sp),
),
content: Obx(() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
'ttsSpeedDefaultHint'.tr, // 默认 +25%
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
),
SizedBox(height: 12.h),
Text(
'+${tempRate.value}%',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
color: primaryColor,
),
),
SizedBox(height: 8.h),
Slider(
value: tempRate.value.toDouble(),
min: 0,
max: 100,
divisions: 20,
label: '+${tempRate.value}%',
activeColor: primaryColor,
onChanged: (value) {
tempRate.value = value.round();
},
),
],
);
}),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () async {
Get.back();
await controller.updateTtsRate(tempRate.value);
},
child: Text('confirm'.tr),
),
],
),
barrierDismissible: true,
);
}
@override
void initState() {
super.initState();
controller = Get.find<AgentController>();
messageActionController = Get.find<MessageActionController>();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
final bottomInset = View.of(context).viewInsets.bottom;
final newKeyboardHeight = bottomInset / View.of(context).devicePixelRatio;
if (_keyboardHeight != newKeyboardHeight) {
_keyboardHeight = newKeyboardHeight;
// 更新键盘可见状态
controller.updateKeyboardVisibility(_keyboardHeight > 0);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primaryColor = theme.primaryColor;
final backgroundColor = theme.scaffoldBackgroundColor;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final isOfficialChannel = Get.isRegistered<VersionUpdateService>() &&
VersionUpdateService.to.cachedChannel == UserChannel.OFFICIAL;
return Scaffold(
appBar: AppBar(
elevation: 1,
backgroundColor: backgroundColor,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black87, size: 20.sp),
onPressed: () => Get.back(),
),
titleSpacing: 0,
title: Row(
children: [
Container(
width: 40.w,
height: 40.w,
decoration: BoxDecoration(
color: primaryColor,
borderRadius: BorderRadius.circular(20.w),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 3.w,
offset: const Offset(0, 1),
),
],
),
child: Icon(
Icons.support_agent,
color: Colors.white,
size: 22.sp,
),
),
SizedBox(width: 12.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
// 机器人名字(品牌名,不做多语言翻译)
'Emai',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
Row(
children: [
Container(
width: 6.w,
height: 6.w,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
SizedBox(width: 4.w),
Text(
'online'.tr,
style: TextStyle(
fontSize: 12.sp,
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
// Obx(() {
// final vipexptime = controller.vipExpiryTime.value;
// DateTime? expiry;
// if (vipexptime > 0) {
// expiry = vipexptime > 1000000000000
// ? DateTime.fromMillisecondsSinceEpoch(vipexptime)
// : DateTime.fromMillisecondsSinceEpoch(
// vipexptime * 1000);
// }
// final text = expiry != null
// ? '${'expiryLabel'.tr}${DateFormat('yyyy-MM-dd').format(expiry)}'
// : 'notActivated'.tr;
// return Text(
// text,
// style: TextStyle(
// fontSize: 12.sp,
// color: Colors.green,
// fontWeight: FontWeight.w500,
// ),
// );
// }),
],
),
],
),
],
),
actions: [
// TTS开关
Obx(() => IconButton(
icon: Icon(
controller.isTtsEnabled.value
? Icons.volume_up
: Icons.volume_off,
size: 24.sp,
color: controller.isTtsEnabled.value
? primaryColor
: isDarkMode
? Colors.grey[400]
: Colors.grey,
),
onPressed: () => controller.toggleTts(),
)),
// 更多选项
Container(
width: 34.w,
height: 34.w,
margin: EdgeInsets.only(right: 8.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.white.withValues(alpha: 0.1)
: Colors.grey[100],
shape: BoxShape.circle,
boxShadow: [
if (!isDarkMode)
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 2.w,
spreadRadius: 0.5.w,
),
],
),
child: PopupMenuButton(
icon: Icon(Icons.more_vert,
color: isDarkMode ? Colors.white : Colors.black87,
size: 20.sp),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
),
color: isDarkMode ? Colors.grey[800] : Colors.white,
itemBuilder: (context) => [
if (isOfficialChannel)
PopupMenuItem(
child: Obx(() => Row(
children: [
Icon(
controller.isShowToolInterruptEnabled.value
? Icons.build_circle
: Icons.build_circle_outlined,
color: controller.isShowToolInterruptEnabled.value
? primaryColor
: (isDarkMode
? Colors.grey[400]
: Colors.grey),
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
"显示工具",
style: TextStyle(fontSize: 14.sp),
),
const Spacer(),
Switch(
value:
controller.isShowToolInterruptEnabled.value,
onChanged: (value) =>
controller.toggleShowAIToolInterrupt(),
activeColor: primaryColor,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
],
)),
onTap: () => controller.toggleTtsInterrupt(),
),
// 播报打断开关选项
PopupMenuItem(
child: Obx(() => Row(
children: [
Icon(
controller.isTtsInterruptEnabled.value
? Icons.voice_over_off
: Icons.voice_over_off_outlined,
color: controller.isTtsInterruptEnabled.value
? primaryColor
: (isDarkMode ? Colors.grey[400] : Colors.grey),
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
//'ttsInterruptToggle'.tr,
"voice_interrupt".tr, // 语音打断
style: TextStyle(fontSize: 14.sp),
),
const Spacer(),
Switch(
value: controller.isTtsInterruptEnabled.value,
onChanged: (value) =>
controller.toggleTtsInterrupt(),
activeColor: primaryColor,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
],
)),
onTap: () => controller.toggleTtsInterrupt(),
),
PopupMenuItem(
child: Row(
children: [
Icon(Icons.speed, color: primaryColor, size: 20.sp),
SizedBox(width: 8.w),
Text('ttsSpeed'.tr, // 语速
style: TextStyle(fontSize: 14.sp)),
],
),
onTap: () => Future.delayed(
const Duration(milliseconds: 100),
() => _showTtsSpeedDialog(),
),
),
// 清除聊天记录选项
PopupMenuItem(
child: Row(
children: [
Icon(Icons.delete_outline,
color: primaryColor, size: 20.sp),
SizedBox(width: 8.w),
Text('clearChatHistory'.tr,
style: TextStyle(fontSize: 14.sp)),
],
),
onTap: () => Future.delayed(
const Duration(milliseconds: 100),
() => controller.clearChat(),
),
),
],
),
),
],
),
body: SafeArea(
top: false,
child: Stack(
children: [
IntroStepBuilder(
group: 'ai',
order: 3,
getOverlayPosition: IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return Obx(
() => IntroOverlayBuilder.homeBuilder(
params,
false,
title: controller.newAIReplyCompleted.value
? 'aiReplyCompleted'.tr
: 'aiThinking'.tr,
onNext: controller.newAIReplyCompleted.value ? () {} : null,
),
);
},
builder: (BuildContext context, GlobalKey key) => SizedBox(
key: key,
),
),
// 主要聊天界面(占满整个 body,底部按钮浮在其上)
GestureDetector(
onTap: () {
// 点击聊天区域时,收起键盘并切换工具显示状态
FocusScope.of(context).unfocus();
if (controller.isPlusOptionsExpanded.value) {
controller.collapsePlusOptions();
}
},
child: Obx(() {
return Align(
alignment: Alignment.topCenter,
child: ListView.builder(
controller: controller.scrollController,
shrinkWrap: true,
reverse: true,
padding: EdgeInsets.only(
top: 16.h,
// 预留浮动通话/挂断按钮的高度,避免最后一条消息被遮挡
bottom: 140.h,
left: 16.w,
right: 16.w,
),
itemCount: controller.messages.length +
(controller.hasMoreMessages.value ? 1 : 0),
itemBuilder: (context, index) {
// 如果是最后一项且有更多消息,显示加载指示器
if (index == controller.messages.length &&
controller.hasMoreMessages.value) {
return _buildLoadMoreIndicator();
}
final message = controller
.messages[controller.messages.length - 1 - index];
return index == 0
? IntroStepBuilder(
group: 'ai',
order: 4,
getOverlayPosition:
IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
'${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['endKey']}'
.tr,
onFinish: () async {
if (controller.noviceGuideService.aiStep <
controller.noviceGuideService
.aiStepsList.length -
1) {
await Future.delayed(
const Duration(milliseconds: 300));
controller.noviceGuideService.aiStep++;
Intro.of(Get.context!)
.start(group: 'ai', reset: true);
}
},
onTap: () async {
params.onFinish();
if (controller.noviceGuideService.aiStep <
controller.noviceGuideService
.aiStepsList.length -
1) {
await Future.delayed(
const Duration(milliseconds: 300));
controller.noviceGuideService.aiStep++;
Intro.of(Get.context!)
.start(group: 'ai', reset: true);
}
},
);
},
builder: (BuildContext context, GlobalKey key) =>
Padding(
key: key,
padding: EdgeInsets.only(bottom: 8.h),
child: GestureDetector(
onLongPressStart:
(LongPressStartDetails details) {
messageActionController.onLongPressMessage(
context, isDarkMode, details, message);
},
child: MessageBubble(
sessionid: message.sessionid,
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
functionCalls: message.functionCalls,
isToolCallsExpanded:
message.isToolCallsExpanded,
),
),
),
)
: Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: GestureDetector(
onLongPressStart:
(LongPressStartDetails details) {
messageActionController.onLongPressMessage(
context, isDarkMode, details, message);
},
child: MessageBubble(
sessionid: message.sessionid,
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
functionCalls: message.functionCalls,
isToolCallsExpanded:
message.isToolCallsExpanded,
),
),
);
},
),
);
}),
),
// 底部输入栏:仿微信「键盘/语音」双模式切换,只保留打字与语音输入
Align(
alignment: Alignment.bottomCenter,
child: Obx(() {
if (controller.isDeviceCallMode.value) {
return const SizedBox.shrink();
}
return _buildInputBar(context, isDarkMode);
}),
),
// 打电话模式覆盖层
Obx(() {
if (controller.isPhoneCallMode.value) {
return _buildPhoneCallOverlay(context, isDarkMode);
}
return const SizedBox.shrink();
}),
// 设备通话模式覆盖层
Obx(() {
if (controller.isDeviceCallMode.value) {
return _buildDeviceCallOverlay(context, isDarkMode);
}
return const SizedBox.shrink();
}),
],
),
),
);
}
// 底部输入栏:仿微信的「键盘 / 语音」双模式。
// 左侧圆形按钮切换模式:文本模式显示麦克风图标(点它切到语音),
// 语音模式显示键盘图标(点它切回打字)。中间随模式变成输入框或「按住 说话」。
// 只做打字 + 语音输入两件事,不含表情/图片/更多面板。
Widget _buildInputBar(BuildContext context, bool isDarkMode) {
final Color barBg = isDarkMode ? const Color(0xFF1C1C1E) : Colors.white;
final Color fieldBg =
isDarkMode ? const Color(0xFF2C2C2E) : const Color(0xFFF2F3F5);
final Color iconColor = isDarkMode ? Colors.white70 : Colors.black54;
final Color textColor = isDarkMode ? Colors.white : Colors.black87;
return Container(
decoration: BoxDecoration(
color: barBg,
border: Border(
top: BorderSide(
color:
isDarkMode ? const Color(0xFF2C2C2E) : const Color(0xFFE5E5E5),
width: 0.5,
),
),
),
// 外层 body 已经包了 SafeArea(top:false),这里不再重复处理底部安全区
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 模式切换按钮
GestureDetector(
onTap: controller.toggleInputMode,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.only(right: 8.w, bottom: 4.h),
child: Icon(
controller.isTextInputMode.value
? Icons.keyboard_voice_outlined
: Icons.keyboard_outlined,
size: 28.sp,
color: iconColor,
),
),
),
// 中间:输入框 / 按住说话
Expanded(
child: controller.isTextInputMode.value
? _buildTextField(fieldBg, textColor)
: _buildHoldToTalkButton(fieldBg, textColor),
),
// 发送按钮:仅文本模式且有内容时出现
if (controller.isTextInputMode.value &&
controller.hasInputText.value)
Padding(
padding: EdgeInsets.only(left: 8.w, bottom: 2.h),
child: GestureDetector(
onTap: controller.sendMessage,
behavior: HitTestBehavior.opaque,
child: Container(
width: 40.h,
height: 40.h,
decoration: const BoxDecoration(
color: Color(0xFF07C160), // 微信绿
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_upward_rounded,
size: 22.sp,
color: Colors.white,
),
),
),
),
],
),
),
);
}
Widget _buildTextField(Color fieldBg, Color textColor) {
return Container(
constraints: BoxConstraints(minHeight: 40.h, maxHeight: 120.h),
decoration: BoxDecoration(
color: fieldBg,
borderRadius: BorderRadius.circular(6.r),
),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
child: TextField(
controller: controller.textController,
style: TextStyle(fontSize: 15.sp, color: textColor),
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: (_) => controller.sendMessage(),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
);
}
/// 按住说话:按下开始识别、松开结束。识别结果由 AgentService 事件回流成消息,
/// 与原来语音链路一致,这里只负责手势与视觉反馈。
Widget _buildHoldToTalkButton(Color fieldBg, Color textColor) {
return GestureDetector(
onLongPressStart: (_) => controller.startVoiceInput(),
onLongPressEnd: (_) => controller.stopVoiceInput(),
onLongPressCancel: () => controller.stopVoiceInput(),
behavior: HitTestBehavior.opaque,
child: Obx(() {
final talking = controller.isListening.value;
return Container(
height: 40.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: talking ? const Color(0xFFD9D9D9) : fieldBg,
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
talking ? 'releaseToSend'.tr : 'holdToTalk'.tr,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: talking ? Colors.black87 : textColor,
),
),
);
}),
);
}
// 构建打电话模式覆盖层
Widget _buildPhoneCallOverlay(BuildContext context, bool isDarkMode) {
return Container(
color: Colors.transparent, // 设置整个覆盖层为透明
child: Column(
children: [
// 中间空白区域 - 完全透明
const Expanded(
child: SizedBox(), // 空的占位符,完全透明
),
// 底部控制按钮 - 包含音频波纹
Container(
color: isDarkMode ? Colors.black : Colors.white,
child: _buildPhoneCallControls(isDarkMode),
),
],
),
);
}
// 构建打电话模式控制按钮
Widget _buildPhoneCallControls(bool isDarkMode) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 录音控制按钮(左侧) - 控制录音输入功能
Obx(() => _buildPhoneControlButton(
icon: controller.isListening.value
? Icons.mic // 录音中显示麦克风图标
: Icons.mic_off, // 未录音显示麦克风关闭图标
onTap: () => controller.togglePhoneCallRecording(),
isDarkMode: isDarkMode,
isActive: controller.isListening.value,
// backgroundColor: controller.isListening.value
// ? Colors.red // 录音中显示红色背景
// : Theme.of(context).primaryColor, // 未录音显示主题色
)),
// 音频波纹组件(中间) - 仅显示动态波形效果,无其他UI元素
Container(
width: 120.w, // 设置固定宽度
height: 64.h, // 与按钮高度一致
alignment: Alignment.center, // 确保波纹居中
child: const AgentSoundBarsWidget(barCount: 12), // 适应宽度的波纹条数
),
// 关闭打电话模式按钮(右侧) - 退出打电话模式
_buildPhoneControlButton(
icon: Icons.close,
onTap: () => controller.endPhoneCall(),
isDarkMode: isDarkMode,
isActive: false,
//backgroundColor: Colors.red,
iconColor: Colors.redAccent,
),
],
),
);
}
// 构建打电话控制按钮
Widget _buildPhoneControlButton({
required IconData icon,
required VoidCallback onTap,
required bool isDarkMode,
required bool isActive,
Color? backgroundColor,
Color? iconColor,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 64.w, // 按钮尺寸
height: 64.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: backgroundColor ??
(isDarkMode ? Colors.grey[800] : Colors.grey[250]),
border: Border.all(color: Colors.blue, width: 2.w),
boxShadow: [
BoxShadow(
color: (backgroundColor ?? Colors.grey).withValues(alpha: 0.3),
blurRadius: 8.w,
spreadRadius: 1.w,
offset: Offset(0, 2.h),
),
],
),
child: Icon(
icon,
color: iconColor ?? (isDarkMode ? Colors.white : Colors.black),
size: 28.sp, // 图标尺寸
),
),
);
}
// ─── 设备通话覆盖层(接听后仅一个挂断按钮,悬浮在聊天界面之上)─────────────────────
Widget _buildDeviceCallOverlay(BuildContext context, bool isDarkMode) {
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => controller.endDeviceCall(),
child: Container(
width: 68.w,
height: 68.w,
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withValues(alpha: 0.35),
blurRadius: 16.w,
offset: Offset(0, 6.h),
),
],
),
child: Transform.rotate(
angle: 2.356,
child: Icon(
Icons.call_end,
color: Colors.white,
size: 30.sp,
),
),
),
),
SizedBox(height: 8.h),
Text(
'挂断',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.grey[300] : Colors.grey[700],
),
),
],
),
),
);
}
// 构建加载更多指示器
Widget _buildLoadMoreIndicator() {
return Obx(
() {
if (controller.isLoadingMore.value) {
// 正在加载状态
return Container(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16.w,
height: 16.h,
child: CircularProgressIndicator(
strokeWidth: 2.w,
),
),
SizedBox(width: 8.w),
Text(
'loadMoreMessages'.tr,
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey,
),
),
],
),
),
);
} else if (controller.isLoadingMoreError.value) {
// 加载失败状态
return Container(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: Center(
child: GestureDetector(
onTap: () => controller.loadMoreMessages(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.refresh,
size: 16.sp,
color: Colors.grey,
),
SizedBox(width: 8.w),
Text(
'loadFailedClickRetry'.tr,
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey,
),
),
],
),
),
),
);
} else {
// 默认状态(有更多消息但未加载)
return Container(
padding: EdgeInsets.symmetric(vertical: 8.h),
child: Center(
child: Text(
'swipeUpForMore'.tr,
style: TextStyle(
fontSize: 12.sp,
color: Colors.grey,
),
),
),
);
}
},
);
}
}

447
apps/client/lib/modules/agent/views/google_navigation_card.dart

@ -1,447 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/agent_controller.dart';
String formatDistance(String distanceStr) {
final distance = double.tryParse(distanceStr);
if (distance == null) return distanceStr; // 如果无法解析,则返回原始字符串
if (distance >= 1000) {
final km = distance / 1000;
return '${km.toStringAsFixed(1)} KM';
} else {
return '${distance.toStringAsFixed(0)} M';
}
}
String formatDuration(String secondsStr) {
int totalSeconds = int.tryParse(secondsStr) ?? 0;
Duration duration = Duration(seconds: totalSeconds);
int hours = duration.inHours;
int minutes = duration.inMinutes.remainder(60);
int seconds = duration.inSeconds.remainder(60);
List<String> parts = [];
if (hours > 0) parts.add('$hours${'hours'.tr}'); // 小时
if (minutes > 0) parts.add('$minutes${'minutes'.tr}'); // 分钟
//不需要秒
//if (seconds > 0 || parts.isEmpty) parts.add('$seconds${'seconds'.tr}'); // 秒
return parts.join('');
}
/// 导航卡片组件
///
/// 各元素含义分析:
/// - [start]: 起点坐标 (经纬度格式)
/// - [end]: 终点坐标 (经纬度格式)
/// - [destination]: 目的地名称显示
/// - [modes]: 导航方式列表,包含驾车、步行、公交等选项
/// - [navigation]: 当前导航状态信息(可选)
/// - [height]: 卡片高度
Widget googlenavigationCard(
BuildContext context, {
required String start,
required String end,
required String destination,
required List<Map<String, dynamic>> modes,
Map<String, dynamic>? navigation,
double? height, // 改为可选参数,允许自适应高度
}) {
int currentIndex = 0;
final controller = Get.find<AgentController>();
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
height: height?.h ?? 350.h, // 如果没有指定高度,使用350作为默认高度
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 12,
color: Colors.black.withValues(alpha: isDarkMode ? 0.4 : 0.08),
offset: const Offset(0, 2),
),
],
),
child: StatefulBuilder(
builder: (context, setInnerState) {
return Column(
children: [
// 顶部:路线预览图片区域
Expanded(
flex: 4, // 增加图片区域的flex值,让图片占更多空间
child: ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
child: Stack(
children: [
// 路线图片
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Image.network(
modes[currentIndex]["image"] as String,
key: ValueKey<String>(
modes[currentIndex]["ntype"] as String),
height: double.infinity,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
child: Icon(
Icons.map_outlined,
size: 48.sp,
color: isDarkMode
? Colors.grey[600]
: Colors.grey[400],
),
);
},
),
),
// 导航状态覆盖层
if (navigation != null)
Positioned(
top: 12.h,
left: 12.w,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 4.h),
decoration: BoxDecoration(
color:
Theme.of(context).primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(12.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.navigation,
color: Colors.white,
size: 14.sp,
),
SizedBox(width: 4.w),
Text(
'navigating'.tr, // 导航中
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
),
// 中部:导航方式选择器
Container(
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w),
child: Row(
children: List.generate(modes.length, (index) {
final mode = modes[index];
final isSelected = index == currentIndex;
final isEnabled = mode['enable'] as bool;
return Expanded(
child: GestureDetector(
onTap: isEnabled
? () {
setInnerState(() => currentIndex = index);
}
: null,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 4.w),
padding: EdgeInsets.symmetric(
vertical: 8.h, horizontal: 12.w),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: (isDarkMode
? Colors.grey[700]!
: Colors.grey[300]!),
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getNavigationIcon(mode["ntype"] as String),
color: isEnabled
? (isSelected
? Theme.of(context).primaryColor
: (isDarkMode
? Colors.white70
: Colors.black87))
: Colors.grey,
size: 20.sp,
),
],
),
),
),
);
}),
),
),
// 底部:目的地信息和操作按钮
Container(
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一层:起点 → 目的地信息(独占整个宽度)
Row(
crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 起点图标 - 统一图标容器宽度,确保对齐
SizedBox(
width: 16.sp, // 固定图标容器宽度
child: Icon(
Icons.my_location,
size: 15.sp, // 统一图标大小
color: Theme.of(context).primaryColor,
),
),
SizedBox(width: 6.w), // 统一间距
// 当前位置文本
Text(
'currentLocation'.tr, // 当前位置
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white70 : Colors.black54,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
SizedBox(width: 8.w),
// 箭头图标
Icon(
Icons.arrow_forward,
size: 15.sp, // 统一图标大小
color: isDarkMode ? Colors.white54 : Colors.black45,
),
SizedBox(width: 8.w),
// 目的地图标
Icon(
Icons.location_on,
size: 15.sp, // 统一图标大小
color: Colors.red,
),
SizedBox(width: 6.w), // 统一间距
// 目的地名称 - 使用Expanded确保自动换行
Expanded(
child: Text(
destination,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
height: 1.2, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
),
SizedBox(height: 4.h), // 第一层和第二层之间的间距
// 第二层:距离时间信息(左侧)+ 导航按钮(右侧)
Row(
crossAxisAlignment: CrossAxisAlignment.end, // 底部对齐
children: [
// 左侧:距离和时间信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 距离信息
Row(
crossAxisAlignment:
CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 距离图标 - 统一图标容器宽度,确保与第一层对齐
SizedBox(
width: 16.sp, // 与第一层图标容器宽度一致
child: Icon(
Icons.straighten,
size: 15.sp, // 统一图标大小
color: Theme.of(context).primaryColor,
),
),
SizedBox(width: 6.w), // 与第一层间距一致
Flexible(
child: Text(
formatDistance(
modes[currentIndex]["distance"]),
style: TextStyle(
fontSize: 14.sp,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
),
SizedBox(height: 4.h),
// 时间信息
Row(
crossAxisAlignment:
CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 时间图标 - 统一图标容器宽度,确保与第一层对齐
SizedBox(
width: 16.sp, // 与第一层图标容器宽度一致
child: Icon(
Icons.access_time,
size: 15.sp, // 统一图标大小
color: Theme.of(context).primaryColor,
),
),
SizedBox(width: 6.w), // 与第一层间距一致
Flexible(
child: Text(
'${'approximate'.tr}${formatDuration(modes[currentIndex]["duration"])}', // 约
style: TextStyle(
fontSize: 14.sp,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
),
// 导航状态信息
if (navigation != null) ...[
SizedBox(height: 6.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 4.h),
decoration: BoxDecoration(
color: Theme.of(context)
.primaryColor
.withOpacity(0.1),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
"${navigation["distanceText"]}${'enterRoad'.tr}${navigation["nextRoad"]}", // 进入
style: TextStyle(
fontSize: 12.sp,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
],
),
),
SizedBox(width: 16.w), // 左右内容之间的间距
// 右侧:开始导航按钮
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor,
Theme.of(context).primaryColor.withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Theme.of(context)
.primaryColor
.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12.r),
onTap: () {
controller.openGoogleMap(
end, modes[currentIndex]["ntype"] as String);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
child: Icon(
Icons.navigation,
color: Colors.white,
size: 20.sp,
),
),
),
),
),
],
),
],
),
),
],
);
},
),
);
}
/// 根据导航类型获取对应图标
IconData _getNavigationIcon(String ntype) {
switch (ntype.toLowerCase()) {
case 'driving':
case '驾车':
return Icons.directions_car;
case 'walking':
case '步行':
return Icons.directions_walk;
case 'transit':
case 'tsransit': // 修正拼写错误
case '公交':
return Icons.directions_transit;
case 'bicycling': // 修正为正确的英文值
case 'cycling':
case '骑行':
return Icons.directions_bike;
default:
return Icons.navigation;
}
}

942
apps/client/lib/modules/agent/views/message_bubble.dart

@ -1,942 +0,0 @@
import '/core/utils/logger.dart';
import '/modules/agent/views/google_navigation_card.dart';
import '/modules/agent/views/music_card.dart';
import '/modules/agent/views/qqmusic_card.dart';
import '/modules/agent/views/weather_baidu.dart';
import '/modules/agent/views/weather_hefeng.dart';
import '../../../data/models/song_model.dart';
import '../../../modules/agent/views/navigation_card.dart';
import 'weather_card.dart';
import 'travel_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // 添加这个导入
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'dart:io';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../controllers/agent_controller.dart';
/// 消息气泡组件
class MessageBubble extends GetView<AgentController> {
final String sessionid;
final bool isUser;
final String message;
final DateTime timestamp;
final bool isRecognizing;
final bool hasImage;
final String? imagePath;
final Map? meta;
final Map<String, Map<String, dynamic>>? functionCalls;
final bool isToolCallsExpanded;
const MessageBubble({
Key? key,
required this.sessionid,
required this.isUser,
required this.message,
required this.timestamp,
this.isRecognizing = false,
this.hasImage = false,
this.imagePath,
this.meta,
this.functionCalls,
this.isToolCallsExpanded = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Column(
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Stack(
children: [
Container(
padding: (hasImage && message == '[图片]')
? EdgeInsets.zero
: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _getBubbleColor(context),
borderRadius: BorderRadius.only(
topLeft: isUser
? const Radius.circular(18)
: const Radius.circular(4),
topRight: isUser
? const Radius.circular(4)
: const Radius.circular(18),
bottomLeft: const Radius.circular(18),
bottomRight: const Radius.circular(18),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Padding(
padding: (controller.isShowToolInterruptEnabled.value &&
!isUser &&
functionCalls != null &&
functionCalls!.isNotEmpty)
? const EdgeInsets.only(top: 30)
: EdgeInsets.zero,
child: _buildMessageContent(context),
),
),
//工具调用流程显示(仅在AI消息且有functionCalls时显示)
if (controller.isShowToolInterruptEnabled.value &&
!isUser &&
functionCalls != null &&
functionCalls!.isNotEmpty)
_buildFunctionCallsWidget(context),
],
),
const SizedBox(height: 4),
// 识别中的消息不显示时间
if (!isRecognizing)
Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.spaceBetween,
children: [
// 时间戳(左对齐或右对齐)
Text(
_formatTimestamp(timestamp),
style: TextStyle(
fontSize: 8,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[400]
: Colors.grey[500],
fontWeight: FontWeight.w400,
),
),
// AI标识(仅在AI消息时显示)
if (!isUser)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
"generatedByAi".tr, // 由AI生成
style: TextStyle(
fontSize: 8,
color: Colors.blue[600],
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic,
),
),
),
],
),
],
),
),
],
),
);
}
/// 获取气泡颜色
Color _getBubbleColor(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
if (isUser) {
return isRecognizing
? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色
: Color(0xFF3B82F6); // 使用更明亮的蓝色
} else {
return isDarkMode
? Color(0xFF2A2A2A)
: Color(0xFFF1F5F9); // 根据主题调整AI消息背景色
}
}
/// 构建消息内容
Widget _buildMessageContent(BuildContext context) {
// 如果是识别中的消息,添加特殊样式
if (isRecognizing) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontStyle: FontStyle.italic,
fontSize: 14,
height: 1.4,
),
// 允许自动换行,不再强制单行
softWrap: true,
maxLines: null,
),
),
const SizedBox(width: 8),
_buildTypingIndicator(),
],
);
}
if (!isUser &&
message.isEmpty &&
(functionCalls == null ||
!controller.isShowToolInterruptEnabled.value) &&
meta == null) {
return _buildTypingIndicator();
}
// 如果只有工具调用而没有文本内容,显示一个占位符确保气泡有足够宽度
if (!isUser &&
message.isEmpty &&
functionCalls != null &&
controller.isShowToolInterruptEnabled.value &&
meta == null) {
return Container(
constraints: const BoxConstraints(
minWidth: 200, // 确保最小宽度
minHeight: 20, // 确保最小高度
),
child: const SizedBox.shrink(), // 透明占位符
);
}
// 如果是图片消息,显示图片
if (hasImage && imagePath != null) {
// 如果消息是默认的[图片]标记,只显示图片
if (message == '[图片]') {
return Container(
padding: const EdgeInsets.all(8),
child: GestureDetector(
onTap: () {
// 点击图片时打开图片查看器
Get.find<AgentController>()
.openImageViewer(context, imagePath ?? '');
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File('${controller.AppDir.value}$imagePath'),
width: 200,
height: 200,
fit: BoxFit.cover,
),
),
),
);
}
// 如果消息是自定义文本,显示文本+图片
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message,
style: TextStyle(
color: isUser ? Colors.white : Colors.black87,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: () {
// 点击图片时打开图片查看器
Get.find<AgentController>()
.openImageViewer(context, imagePath ?? '');
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File('${controller.AppDir.value}$imagePath'),
width: 200,
height: 200,
fit: BoxFit.cover,
),
),
),
],
);
}
// 判断消息内容,显示特殊卡片
if (!isUser && meta != null) {
if (meta!.keys.contains('music_searchandplay_songs')) {
final music_search_songs =
meta!['music_searchandplay_songs'] as Map<String, dynamic>;
final data = music_search_songs['data'];
if (data != null && data is List) {
final rawList = data as List? ?? [];
final playlist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
//音乐播放列表
return buildMusicContent(context, playlist);
}
} else if (meta!.keys.contains('qqmusic_search_songs')) {
final qqmusic_search_songs =
meta!['qqmusic_search_songs'] as Map<String, dynamic>;
final data = qqmusic_search_songs['data'];
if (data != null && data is List) {
//qq音乐
final rawList = data as List? ?? [];
final songList = rawList
.map((item) {
try {
return SongModel.fromJson(item);
} catch (e) {
Logger.e('QQMusicPlaylist', '解析歌曲数据失败: $e, 数据: $item');
return null;
}
})
.where((song) => song != null)
.cast<SongModel>()
.toList();
//音乐播放列表
return buildQQMusicContent(context, songList);
}
} else if (meta!.keys.contains('hefeng_weather')) {
//高德的
final hefeng_weather = meta!['hefeng_weather'] as Map<String, dynamic>;
final weather_city = hefeng_weather['city'];
final weather_day = hefeng_weather['day'];
final weather_data = hefeng_weather['data'];
if (weather_city != null) {
final rawList = weather_data as List? ?? [];
final daylist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
// final now = daylist.isNotEmpty ? daylist[0] : <String, dynamic>{};
final now = daylist.firstWhere(
(day) => day['fxDate'] == weather_day,
orElse: () => daylist.isNotEmpty ? daylist[0] : <String, dynamic>{},
);
//天气
return hefengWeatherContent(
context,
weather_city,
now,
daylist,
);
}
} else if (meta!.keys.contains('maps_weather')) {
//高德的
final maps_weather = meta!['maps_weather'] as Map<String, dynamic>;
final city = maps_weather['city'];
if (city != null) {
final rawList = maps_weather['forecasts'] as List? ?? [];
final daylist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
final now = daylist.isNotEmpty ? daylist[0] : <String, dynamic>{};
//天气
return buildWeatherContent(
context,
city,
now,
daylist,
);
}
} else if (meta!.keys.contains('map_weather')) {
//百度的
final map_weather = meta!['map_weather'] as Map<String, dynamic>;
final status = map_weather['status'];
if (status == 0) {
final result = map_weather['result'] as Map<String, dynamic>;
final city = result["location"]["city"] as String? ?? '';
final rawList = result['forecasts'] as List? ?? [];
final daylist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
final now = daylist.isNotEmpty ? daylist[0] : <String, dynamic>{};
//天气
return buildWeatherBaiduContent(
context,
city,
now,
daylist,
);
}
} else if (meta!.keys.contains('card_travel')) {
final rawList = meta!['card_travel']['items'] as List? ?? [];
final travellist =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
// 旅游
return buildTravelContent(context, travellist);
} else if (meta!.keys.contains('maps_route_navigation')) {
final maps_route_navigation = meta!['maps_route_navigation'];
if (maps_route_navigation is Map<String, dynamic>) {
final card_navigation =
maps_route_navigation["card_navigation"] as Map<String, dynamic>;
final start = card_navigation['start'] as String? ?? '';
final end = card_navigation['end'] as String? ?? '';
final end_longitude = card_navigation["end_longitude"] as String;
final end_latitude = card_navigation["end_latitude"] as String;
final destination = card_navigation['destination'] as String? ?? '';
final rawList = card_navigation['items'] as List? ?? null;
if (rawList != null) {
final list =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
final rawNavigation = card_navigation['navigation'];
final Map<String, dynamic>? navigation =
rawNavigation is Map<String, dynamic> ? rawNavigation : null;
// print("导航实时数据 ${rawNavigation}");
// 导航
return navigationCard(context,
start: start,
end: end,
endlatitude: end_latitude,
endlongitude: end_longitude,
destination: destination,
modes: list,
navigation: navigation);
}
}
} else if (meta!.keys.contains('maps_google_route_navigation')) {
final maps_google_route_navigation =
meta!['maps_google_route_navigation'];
if (maps_google_route_navigation is Map<String, dynamic>) {
final data = maps_google_route_navigation['data'];
if (data != null) {
final origin = data['origin'] as String? ?? '';
final destination = data['destination'] as String? ?? '';
final end = data['end'] as String? ?? '';
final rawList = data['items'] as List? ?? null;
if (rawList != null) {
final list =
rawList.map((e) => Map<String, dynamic>.from(e)).toList();
final rawNavigation = data['navigation'];
final Map<String, dynamic>? navigation =
rawNavigation is Map<String, dynamic> ? rawNavigation : null;
// print("导航实时数据 ${rawNavigation}");
// 导航
return googlenavigationCard(context,
start: origin,
end: destination,
destination: end,
modes: list,
navigation: navigation);
}
}
}
}
// if (message.contains('播放列表') || message.contains('音乐')) {
// return _buildMusicContent(context);
// } else if (message.contains('景点') || message.contains('旅游')) {
// return _buildTravelContent(context);
// } else if (message.contains('天气') ||
// message.contains('气温') ||
// message.contains('预报')) {
// return _buildWeatherContent(context);
// }
}
// 创建消息内容Widget
Widget messageContent;
// 使用Markdown渲染AI消息,用户消息仍然使用普通文本
if (!isUser) {
messageContent = MarkdownBody(
data: message,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.9)
: Colors.black87,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
),
h1: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
fontSize: 18,
fontWeight: FontWeight.bold,
),
h2: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
fontSize: 16,
fontWeight: FontWeight.bold,
),
h3: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
fontSize: 15,
fontWeight: FontWeight.bold,
),
code: TextStyle(
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Colors.grey[800]
: Colors.grey[200],
fontFamily: 'monospace',
fontSize: 13,
),
codeblockDecoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[800]
: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
blockquote: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[400]
: Colors.grey[700],
fontSize: 14,
fontStyle: FontStyle.italic,
),
blockquoteDecoration: BoxDecoration(
border: Border(
left: BorderSide(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[600]!
: Colors.grey[400]!,
width: 4,
),
),
),
listBullet: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.9)
: Colors.black87,
fontSize: 14,
),
),
//selectable: true,
);
} else {
// 用户消息仍然使用普通文本
messageContent = Text(
message,
style: TextStyle(
color: isUser
? Colors.white
: (Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.9)
: Colors.black87),
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
),
);
}
// 如果是AI消息且有工具调用,确保最小宽度以防止工具调用列表越界
if (!isUser && functionCalls != null && functionCalls!.isNotEmpty) {
return Container(
constraints: const BoxConstraints(
minWidth: 200, // 确保最小宽度,防止工具调用列表越界
),
child: messageContent,
);
}
return messageContent;
}
/// 构建输入中的指示器
Widget _buildTypingIndicator() {
return SizedBox(
width: 40,
height: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDot(100),
_buildDot(300),
_buildDot(500),
],
),
);
}
/// 构建动画点
Widget _buildDot(int milliseconds) {
return _AnimatedDot(delayMilliseconds: milliseconds);
}
/// 智能格式化时间戳
String _formatTimestamp(DateTime messageTime) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final messageDate =
DateTime(messageTime.year, messageTime.month, messageTime.day);
// 计算时间差
final difference = now.difference(messageTime);
// 1分钟内显示"刚刚"
if (difference.inMinutes < 1) {
return 'justNow'.tr; // 刚刚
}
// 1小时内显示"X分钟前"
if (difference.inHours < 1) {
return 'minutesAgo'
.trParams({'minutes': difference.inMinutes.toString()}); // X分钟前
}
// 今天的消息显示时间
if (messageDate == today) {
return DateFormat('HH:mm').format(messageTime);
}
// 昨天的消息显示"昨天 HH:mm"
if (messageDate == yesterday) {
return 'yesterday'.tr +
' ' +
DateFormat('HH:mm').format(messageTime); // 昨天 HH:mm
}
// 本周内的消息显示"星期X HH:mm"
if (difference.inDays < 7) {
final weekday = _getWeekdayName(messageTime.weekday);
return weekday + ' ' + DateFormat('HH:mm').format(messageTime);
}
// 今年的消息显示"MM-dd HH:mm"
if (messageTime.year == now.year) {
return DateFormat('MM-dd HH:mm').format(messageTime);
}
// 其他情况显示完整日期"yyyy-MM-dd HH:mm"
return DateFormat('yyyy-MM-dd HH:mm').format(messageTime);
}
/// 获取星期名称
String _getWeekdayName(int weekday) {
switch (weekday) {
case 1:
return 'monday'.tr; // 星期一
case 2:
return 'tuesday'.tr; // 星期二
case 3:
return 'wednesday'.tr; // 星期三
case 4:
return 'thursday'.tr; // 星期四
case 5:
return 'friday'.tr; // 星期五
case 6:
return 'saturday'.tr; // 星期六
case 7:
return 'sunday'.tr; // 星期日
default:
return '';
}
}
/// 构建工具调用流程组件
Widget _buildFunctionCallsWidget(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 0, vertical: 0), // 减少垂直padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Container(
height: 40, // 增加固定高度,给字体更多空间
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? const Color.fromARGB(255, 71, 68, 68)?.withOpacity(0.8)
: Colors.grey[100]?.withOpacity(0.9),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
border: Border.all(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.1)
: Colors.black.withOpacity(0.1),
width: 0.5,
),
),
child: Row(
children: [
Icon(
Icons.build, // 工具icon
size: 20, // 减小图标大小
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.7)
: Colors.black54,
),
const SizedBox(width: 4),
// 显示最后一个工具名称 - 可点击展开
Expanded(
child: GestureDetector(
onTap: () async {
await controller.updateToolCallsExpanded(
sessionid,
!isToolCallsExpanded,
);
},
child: Text(
functionCalls != null && functionCalls!.isNotEmpty
? functionCalls!.keys.last
: 'toolCall'.tr,
style: TextStyle(
fontSize: 16, // 适当增大字体
color:
Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.7)
: Colors.black54,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 4),
// 拷贝按钮
GestureDetector(
onTap: () {
_copyFunctionCallsToClipboard();
},
child: Container(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.copy,
size: 20,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.7)
: Colors.black54,
),
),
),
const SizedBox(width: 4),
// 展开按钮
GestureDetector(
onTap: () async {
await controller.updateToolCallsExpanded(
sessionid,
!isToolCallsExpanded,
);
},
child: Container(
padding: const EdgeInsets.all(2), // 增加 padding 从 2 到 8
child: Icon(
isToolCallsExpanded
? Icons.keyboard_arrow_down // 展开时显示向上箭头
: Icons.keyboard_arrow_up, // 收起时显示向下箭头
size: 20, // 增加图标大小从 14 到 16
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.7)
: Colors.black54,
),
),
),
],
),
),
// 展开的工具调用列表
if (isToolCallsExpanded && functionCalls != null)
Container(
margin: const EdgeInsets.only(top: 2), // 减少顶部间距
padding: const EdgeInsets.all(6), // 减少内边距
constraints: const BoxConstraints(
maxHeight: 120, // 限制最大高度,避免超界
),
decoration: BoxDecoration(
// 添加更强的背景色来遮挡下层UI
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[900]?.withOpacity(0.95) // 深色模式下使用深灰色背景
: Colors.white.withOpacity(0.95), // 浅色模式下使用白色背景
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(6),
bottomRight: Radius.circular(6),
),
border: Border.all(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withOpacity(0.2)
: Colors.black.withOpacity(0.2),
width: 0.5,
),
// 添加阴影增强遮挡效果
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: SingleChildScrollView(
// 添加滚动,防止超界
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: functionCalls!.entries.map((entry) {
String toolName = entry.key;
dynamic toolResult = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8), // 减少间距
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 工具名称 - 固定宽度
SizedBox(
width: 80, // 固定宽度
child: Text(
toolName,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).brightness ==
Brightness.dark
? Colors.white.withOpacity(0.6)
: Colors.black45,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12), // 增加间距
// 工具结果 - 剩余空间
Expanded(
child: Text(
toolResult?.toString() ?? 'noResult'.tr,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).brightness ==
Brightness.dark
? Colors.white.withOpacity(0.8)
: Colors.black87,
height: 1.1, // 减小行高
),
),
),
],
),
);
}).toList(),
),
),
),
],
),
));
}
/// 拷贝工具调用内容到剪贴板
void _copyFunctionCallsToClipboard() {
if (functionCalls == null || functionCalls!.isEmpty) {
Get.snackbar(
'tip'.tr,
'noToolCallContentToCopy'.tr,
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
return;
}
StringBuffer buffer = StringBuffer();
buffer.writeln('toolCallDetails'.tr);
buffer.writeln('=' * 30);
functionCalls!.forEach((toolName, toolData) {
buffer.writeln('🔧 工具名称: $toolName');
if (toolData is Map) {
toolData.forEach((key, value) {
buffer.writeln(' $key: $value');
});
} else {
buffer.writeln(' 结果: $toolData');
}
buffer.writeln('-' * 20);
});
Clipboard.setData(ClipboardData(text: buffer.toString()));
Get.snackbar(
'success'.tr,
'toolCallContentCopied'.tr,
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
}
}
/// 动画点组件
class _AnimatedDot extends StatefulWidget {
final int delayMilliseconds;
const _AnimatedDot({required this.delayMilliseconds});
@override
_AnimatedDotState createState() => _AnimatedDotState();
}
class _AnimatedDotState extends State<_AnimatedDot>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_animation = Tween<double>(begin: 0, end: 6).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.5, curve: Curves.easeOut),
reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn),
),
)..addListener(() {
setState(() {});
});
Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () {
if (mounted) {
_controller.repeat();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(3),
boxShadow: [
BoxShadow(
color: Colors.white.withOpacity(0.5),
blurRadius: _animation.value,
spreadRadius: _animation.value / 2,
),
],
),
);
}
}

275
apps/client/lib/modules/agent/views/music_card.dart

@ -1,275 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../../routes/app_routes.dart';
import '../controllers/agent_controller.dart';
import '../widgets/circular_progress_button.dart';
/// 构建音乐项目
Widget buildMusicItem(
BuildContext context, int id, String name, String singer, String image) {
final controller = Get.find<AgentController>(); // 获取控制器
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
Map<String, dynamic> song = {
'id': id,
'title': name,
'artist': singer,
'coverUrl': image,
};
return Container(
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03),
blurRadius: 3,
spreadRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
Container(
width: 50.w,
height: 50.h,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[100],
borderRadius: BorderRadius.circular(8.r),
),
child: Stack(
children: [
// 主图片
Container(
width: 50.w,
height: 50.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
image: image.isNotEmpty
? DecorationImage(
image: NetworkImage(image),
fit: BoxFit.cover,
)
: null,
),
child: image.isEmpty
? Icon(
Icons.music_note,
size: 18.sp,
color: Theme.of(context).primaryColor,
)
: null,
),
// 右下角音乐图标
if (image.isNotEmpty)
Positioned(
right: 2.w,
bottom: 2.w,
child: Container(
width: 16.w,
height: 16.w,
// decoration: BoxDecoration(
// color: Colors.white,
// borderRadius: BorderRadius.circular(2.r),
// boxShadow: [
// BoxShadow(
// color: Colors.black.withOpacity(0.2),
// blurRadius: 1,
// offset: const Offset(0, 0.5),
// ),
// ],
// ),
child: ClipRRect(
borderRadius: BorderRadius.circular(2.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
),
],
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: isDarkMode
? Colors.white.withValues(alpha: 0.9)
: Colors.grey[800],
),
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 2.h),
Text(
singer,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
],
),
),
Obx(() {
final isThisPlaying =
controller.isPlaying.value && controller.currentId.value == id;
// 只有当前播放的歌曲才显示进度
final currentProgress =
isThisPlaying ? controller.progress.value : 0.0;
return CircularProgressButton(
isPlaying: isThisPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 36.0,
strokeWidth: 2.0,
onPlayPause: () async {
try {
if (controller.isPlaying.value) {
if (controller.currentId.value == id) {
// 当前歌曲正在播放,暂停它
await controller.musicPause();
} else {
// 其他歌曲正在播放,播放这首歌
await controller.musicPlay(song);
}
} else {
if (controller.currentId.value == id) {
// 当前歌曲已暂停,恢复播放
await controller.musicResume();
} else {
// 播放新歌曲
await controller.musicPlay(song);
}
}
} catch (e) {
// 静默处理错误,避免影响用户体验
}
},
onSeek: isThisPlaying
? (seekProgress) {
// 只有当前播放的歌曲才允许跳转
final targetPosition =
(controller.duration.value * seekProgress).round();
controller.seekToPosition(targetPosition);
}
: null,
);
}),
],
),
);
}
/// 构建音乐内容卡片
Widget buildMusicContent(
BuildContext context, List<Map<String, dynamic>> playlist) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final controller = Get.find<AgentController>(); // 获取控制器
controller.updateCurrentPlaylistContext(playlist, controller.currentId.value);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'recommendedMusic'.tr, //以下是一些推荐的音乐:
style: TextStyle(
color:
isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 18.w,
height: 18.w,
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
const SizedBox(width: 6),
Text(
'playList'.tr, //播放列表
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[300]
: Colors.grey[700],
),
),
],
),
const SizedBox(height: 10),
// 动态生成音乐条目
...playlist.asMap().entries.take(4).map((entry) {
final index = entry.key;
final item = entry.value;
final id = item["id"];
final name = item["name"] ?? "未知歌曲";
final singer = item["singer"] ?? "未知歌手";
final image = item["image"] ?? "";
return Column(
children: [
buildMusicItem(context, id, name, singer, image),
if (index != playlist.length - 1)
Divider(
height: 1,
thickness: 1,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[800]
: Colors.grey[100],
),
],
);
}).toList(),
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: () {
// 跳转到完整歌单页面,传递完整的歌单数据
Get.toNamed(Routes.musicPlaylist, arguments: playlist);
},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'moreMusic'.tr, //更多音乐,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
);
}

472
apps/client/lib/modules/agent/views/navigation_card.dart

@ -1,472 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/agent_controller.dart';
String formatDistance(String distanceStr) {
final distance = double.tryParse(distanceStr);
if (distance == null) return distanceStr; // 如果无法解析,则返回原始字符串
if (distance >= 1000) {
final km = distance / 1000;
return '${km.toStringAsFixed(1)} KM';
} else {
return '${distance.toStringAsFixed(0)} M';
}
}
String formatDuration(String secondsStr) {
int totalSeconds = int.tryParse(secondsStr) ?? 0;
Duration duration = Duration(seconds: totalSeconds);
int hours = duration.inHours;
int minutes = duration.inMinutes.remainder(60);
int seconds = duration.inSeconds.remainder(60);
List<String> parts = [];
if (hours > 0) parts.add('$hours${'hours'.tr}'); // 小时
if (minutes > 0) parts.add('$minutes${'minutes'.tr}'); // 分钟
//不需要秒
//if (seconds > 0 || parts.isEmpty) parts.add('$seconds${'seconds'.tr}'); // 秒
return parts.join('');
}
/// 导航卡片组件
///
/// 各元素含义分析:
/// - [start]: 起点坐标 (经纬度格式)
/// - [end]: 终点坐标 (经纬度格式)
/// - [destination]: 目的地名称显示
/// - [modes]: 导航方式列表,包含驾车、步行、公交等选项
/// - [navigation]: 当前导航状态信息(可选)
/// - [height]: 卡片高度
Widget navigationCard(
BuildContext context, {
required String start,
required String end,
required String endlatitude,
required String endlongitude,
required String destination,
required List<Map<String, dynamic>> modes,
Map<String, dynamic>? navigation,
double? height, // 改为可选参数,允许自适应高度
}) {
int currentIndex = 0;
final controller = Get.find<AgentController>();
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
height: height?.h ?? 350.h, // 如果没有指定高度,使用350作为默认高度
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 12,
color: Colors.black.withValues(alpha: isDarkMode ? 0.4 : 0.08),
offset: const Offset(0, 2),
),
],
),
child: StatefulBuilder(
builder: (context, setInnerState) {
return Column(
children: [
// 顶部:路线预览图片区域
Expanded(
flex: 4, // 增加图片区域的flex值,让图片占更多空间
child: ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
child: Stack(
children: [
// 路线图片
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Image.network(
modes[currentIndex]["image"] as String,
key: ValueKey<String>(
modes[currentIndex]["ntype"] as String),
height: double.infinity,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
child: Icon(
Icons.map_outlined,
size: 48.sp,
color: isDarkMode
? Colors.grey[600]
: Colors.grey[400],
),
);
},
),
),
// 导航状态覆盖层
if (navigation != null)
Positioned(
top: 12.h,
left: 12.w,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 4.h),
decoration: BoxDecoration(
color:
Theme.of(context).primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(12.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.navigation,
color: Colors.white,
size: 14.sp,
),
SizedBox(width: 4.w),
Text(
'navigating'.tr, // 导航中
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
),
// 中部:导航方式选择器
Container(
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w),
child: Row(
children: List.generate(modes.length, (index) {
final mode = modes[index];
final isSelected = index == currentIndex;
final isEnabled = mode['enable'] as bool;
return Expanded(
child: GestureDetector(
onTap: isEnabled
? () {
setInnerState(() => currentIndex = index);
}
: null,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 4.w),
padding: EdgeInsets.symmetric(
vertical: 8.h, horizontal: 12.w),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: (isDarkMode
? Colors.grey[700]!
: Colors.grey[300]!),
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getNavigationIcon(mode["ntype"] as String),
color: isEnabled
? (isSelected
? Theme.of(context).primaryColor
: (isDarkMode
? Colors.white70
: Colors.black87))
: Colors.grey,
size: 20.sp,
),
// SizedBox(height: 4.h),
// Text(
// (mode["ntype"] as String).tr,
// textAlign: TextAlign.center,
// style: TextStyle(
// color: isEnabled
// ? (isSelected
// ? Theme.of(context).primaryColor
// : (isDarkMode
// ? Colors.white70
// : Colors.black87))
// : Colors.grey,
// fontSize: 11.sp,
// fontWeight: isSelected
// ? FontWeight.w600
// : FontWeight.w400,
// ),
// ),
],
),
),
),
);
}),
),
),
// 底部:目的地信息和操作按钮
Container(
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一层:起点 → 目的地信息(独占整个宽度)
Row(
crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 起点图标 - 统一图标容器宽度,确保对齐
SizedBox(
width: 16.sp, // 固定图标容器宽度
child: Icon(
Icons.my_location,
size: 15.sp, // 统一图标大小
color: Theme.of(context).primaryColor,
),
),
SizedBox(width: 6.w), // 统一间距
// 当前位置文本
Text(
'currentLocation'.tr, // 当前位置
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white70 : Colors.black54,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
SizedBox(width: 8.w),
// 箭头图标
Icon(
Icons.arrow_forward,
size: 15.sp, // 统一图标大小
color: isDarkMode ? Colors.white54 : Colors.black45,
),
SizedBox(width: 8.w),
// 目的地图标
Icon(
Icons.location_on,
size: 15.sp, // 统一图标大小
color: Colors.red,
),
SizedBox(width: 6.w), // 统一间距
// 目的地名称 - 使用Expanded确保自动换行
Expanded(
child: Text(
destination,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
height: 1.2, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
),
SizedBox(height: 4.h), // 第一层和第二层之间的间距
// 第二层:距离时间信息(左侧)+ 导航按钮(右侧)
Row(
crossAxisAlignment: CrossAxisAlignment.end, // 底部对齐
children: [
// 左侧:距离和时间信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 距离信息
Row(
crossAxisAlignment:
CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 距离图标 - 统一图标容器宽度,确保与第一层对齐
SizedBox(
width: 16.sp, // 与第一层图标容器宽度一致
child: Icon(
Icons.straighten,
size: 15.sp, // 统一图标大小
color: Theme.of(context).primaryColor,
),
),
SizedBox(width: 6.w), // 与第一层间距一致
Flexible(
child: Text(
formatDistance(
modes[currentIndex]["distance"]),
style: TextStyle(
fontSize: 14.sp,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
),
SizedBox(height: 4.h),
// 时间信息
Row(
crossAxisAlignment:
CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 时间图标 - 统一图标容器宽度,确保与第一层对齐
SizedBox(
width: 16.sp, // 与第一层图标容器宽度一致
child: Icon(
Icons.access_time,
size: 15.sp, // 统一图标大小
color: Theme.of(context).primaryColor,
),
),
SizedBox(width: 6.w), // 与第一层间距一致
Flexible(
child: Text(
'${'approximate'.tr}${formatDuration(modes[currentIndex]["duration"])}', // 约
style: TextStyle(
fontSize: 14.sp,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
),
// 导航状态信息
if (navigation != null) ...[
SizedBox(height: 6.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 4.h),
decoration: BoxDecoration(
color: Theme.of(context)
.primaryColor
.withOpacity(0.1),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
"${navigation["distanceText"]}${'enterRoad'.tr}${navigation["nextRoad"]}", // 进入
style: TextStyle(
fontSize: 12.sp,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
height: 1.3, // 设置行高,提升多行文本可读性
),
softWrap: true, // 启用软换行
overflow: TextOverflow.visible, // 确保文本完全显示
),
),
],
],
),
),
SizedBox(width: 16.w), // 左右内容之间的间距
// 右侧:开始导航按钮
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor,
Theme.of(context).primaryColor.withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Theme.of(context)
.primaryColor
.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12.r),
onTap: () {
controller.openNavigation(
true,
start,
destination,
endlatitude,
endlongitude,
modes[currentIndex]["ntype"] as String);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 12.h,
),
child: Icon(
Icons.navigation,
color: Colors.white,
size: 20.sp,
),
),
),
),
),
],
),
],
),
),
],
);
},
),
);
}
/// 根据导航类型获取对应图标
IconData _getNavigationIcon(String ntype) {
switch (ntype.toLowerCase()) {
case 'driving':
case '驾车':
return Icons.directions_car;
case 'walking':
case '步行':
return Icons.directions_walk;
case 'transit':
case 'tsransit': // 修正拼写错误
case '公交':
return Icons.directions_transit;
case 'bicycling': // 修正为正确的英文值
case 'cycling':
case '骑行':
return Icons.directions_bike;
default:
return Icons.navigation;
}
}

265
apps/client/lib/modules/agent/views/qqmusic_card.dart

@ -1,265 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '/modules/agent/widgets/circular_progress_button.dart';
import '../../../routes/app_routes.dart';
import '../../../data/services/qq_music.dart';
import '../../../data/models/song_model.dart';
/// 构建音乐项目
Widget buildQQMusicItem(
BuildContext context, List<SongModel> songs, SongModel song, int index) {
final qqMusicService = QqMusicService.to; // 使用QQ音乐全局服务
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03),
blurRadius: 3,
spreadRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
Container(
width: 50.w,
height: 50.h,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[100],
borderRadius: BorderRadius.circular(8.r),
),
child: Stack(
children: [
// 主图片
Container(
width: 50.w,
height: 50.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
image: song.albumPic!.isNotEmpty
? DecorationImage(
image: NetworkImage(song.albumPic!),
fit: BoxFit.cover,
)
: null,
),
child: song.albumPic!.isEmpty
? Icon(
Icons.music_note,
size: 18.sp,
color: Theme.of(context).primaryColor,
)
: null,
),
// 右下角音乐图标
if (song.albumPic!.isNotEmpty)
Positioned(
right: 0.w,
bottom: 0.w,
child: Container(
width: 12.w,
height: 12.w,
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
],
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
song.name,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: isDarkMode
? Colors.white.withValues(alpha: 0.9)
: Colors.grey[800],
),
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 2.h),
Text(
song.artist,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
],
),
),
Obx(() {
// 使用QQ音乐服务的全局状态
final isThisPlaying = qqMusicService.isPlaying.value &&
qqMusicService.currentSong.value?.mid == song.mid;
// 计算播放进度
final currentProgress =
isThisPlaying && qqMusicService.totalDuration.value > 0
? qqMusicService.currentPosition.value /
qqMusicService.totalDuration.value
: 0.0;
return CircularProgressButton(
isPlaying: isThisPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 36.0,
strokeWidth: 2.0,
onPlayPause: () {
try {
if (qqMusicService.isPlaying.value) {
if (qqMusicService.currentSong.value?.mid == song.mid) {
// 当前歌曲正在播放,暂停它
qqMusicService.pauseMusic();
} else {
final songMids = songs.map((s) => s.mid).toList();
// 其他歌曲正在播放,播放这首歌
qqMusicService.playSongs(
songMids: songMids,
index: index,
songList: [song],
currentSongModel: song,
);
}
} else {
if (qqMusicService.currentSong.value?.mid == song.mid) {
// 当前歌曲已暂停,恢复播放
qqMusicService.resumeMusic();
} else {
final songMids = songs.map((s) => s.mid).toList();
// 播放新歌曲
qqMusicService.playSongs(
songMids: songMids,
index: index,
songList: [song],
currentSongModel: song,
);
}
}
} catch (e) {
// 静默处理错误,避免影响用户体验
print('播放控制失败: $e');
}
},
onSeek: isThisPlaying
? (seekProgress) {
// QQ音乐暂不支持拖拽进度,可以在这里添加相关逻辑
// 或者显示提示信息
print('QQ音乐暂不支持拖拽进度');
}
: null,
);
}),
],
),
);
}
/// 构建音乐内容卡片
Widget buildQQMusicContent(BuildContext context, List<SongModel> songs) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'recommendedMusic'.tr, //以下是一些推荐的音乐:
style: TextStyle(
color:
isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 18.w,
height: 18.w,
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
const SizedBox(width: 6),
Text(
'playList'.tr, //播放列表
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[300]
: Colors.grey[700],
),
),
],
),
const SizedBox(height: 10),
// 动态生成音乐条目
...songs.asMap().entries.take(4).map((entry) {
final index = entry.key;
final item = entry.value;
return Column(
children: [
buildQQMusicItem(context, songs, item, index),
if (index != songs.length - 1)
Divider(
height: 1,
thickness: 1,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[800]
: Colors.grey[100],
),
],
);
}).toList(),
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: () {
// 跳转到完整歌单页面,传递完整的歌单数据
Get.toNamed(Routes.qqmusic, arguments: {"playlist": songs});
},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'moreMusic'.tr, //更多音乐,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
);
}

223
apps/client/lib/modules/agent/views/travel_card.dart

@ -1,223 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 构建旅游内容卡片
Widget buildTravelContent(
BuildContext context, List<Map<String, dynamic>> travellist) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'以下是一些推荐搜索结果:',
style: TextStyle(
color: isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14.sp,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.h),
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03),
blurRadius: 3,
spreadRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row(
// children: [
// Icon(
// Icons.place,
// size: 16,
// color: Color(0xFFE11D48),
// ),
// const SizedBox(width: 6),
// Text(
// '热门景点 · 东京',
// style: TextStyle(
// fontSize: 13,
// fontWeight: FontWeight.w600,
// color: Colors.grey[700],
// ),
// ),
// ],
// ),
// const SizedBox(height: 10),
// 动态生成旅游景点条目
...travellist.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final name = item["name"] ?? "未知景点"; //名字
final addr = item["addr"] ?? ""; //地址
// final tag = item["tag"] ?? "未知标签"; //标签
// final rating = item["rating"] ?? ""; //评分
// final distance = item["distance"] ?? ""; //距离
// final images = item["images"] ?? []; // 图片列表
return Column(
children: [
buildLocationItem(
context,
name,
addr,
),
if (index != travellist.length - 1)
Divider(
height: 1.h,
thickness: 1,
color: isDarkMode ? Colors.grey[700] : Colors.grey[100]
),
],
);
}).toList(),
// // 景点列表
// _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context),
// Divider(height: 1, thickness: 1, color: Colors.grey[100]),
// _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context),
// Divider(height: 1, thickness: 1, color: Colors.grey[100]),
// _buildLocationItem('浅草寺', '东京最古老的寺庙', context),
SizedBox(height: 8.h),
Center(
child: TextButton(
onPressed: () {},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'查看更多景点',
style: TextStyle(
fontSize: 12.sp,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
SizedBox(height: 10.h),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
icon: Icon(Icons.map, size: 16.sp),
label: const Text('查看地图'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).primaryColor,
side: BorderSide(
color: Theme.of(context).primaryColor.withValues(alpha: 0.5)),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
textStyle: TextStyle(fontSize: 12.sp),
),
),
),
SizedBox(width: 8.w),
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
icon: Icon(Icons.bookmark_border, size: 16.sp),
label: const Text('保存到收藏'),
style: OutlinedButton.styleFrom(
foregroundColor: isDarkMode ? Colors.grey[400] : Colors.grey[700],
side: BorderSide(color: isDarkMode ? Colors.grey[600]! : Colors.grey[400]!),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
textStyle: TextStyle(fontSize: 12.sp),
),
),
),
],
),
],
);
}
/// 构建地点项目
Widget buildLocationItem(
BuildContext context, String name, String description) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: EdgeInsets.symmetric(vertical: 8.h),
child: Row(
children: [
Container(
width: 38.w,
height: 38.h,
decoration: BoxDecoration(
color: isDarkMode
? const Color(0xFF2A2A2A)
: Theme.of(context).primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
Icons.place,
size: 18.sp,
color: Theme.of(context).primaryColor,
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: isDarkMode
? Colors.white.withValues(alpha: 0.9)
: Colors.grey[800],
),
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 2.h),
Text(
description,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: () {},
icon: Icon(
Icons.arrow_forward,
color: isDarkMode ? Colors.grey[500] : Colors.grey[400]
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
iconSize: 20.sp,
),
],
),
);
}

562
apps/client/lib/modules/agent/views/weather_baidu.dart

@ -1,562 +0,0 @@
import 'package:common_utils/common_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
/// 构建天气内容卡片
Widget buildWeatherBaiduContent(BuildContext context, String city,
Map<String, dynamic> now, List<Map<String, dynamic>> days) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'weatherQuery'.tr, // 翻译键代表的中文文本内容:查询天气情况:
style: TextStyle(
color:
isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14.sp,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.h),
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
gradient: _getWeatherGradient(now['text_day'] ?? '', isDarkMode),
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.3 : 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"${city} ${now["date"]}",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 4.w),
Text(
_getWeatherDescriptionTranslation(now["text_day"]),
style: TextStyle(
fontSize: 14.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
SizedBox(height: 8.h),
Row(
children: [
Text(
"${now["high"]}°",
style: TextStyle(
fontSize: 36.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(width: 8.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${'highest'.tr}: ${now["high"]}°', // 翻译键代表的中文文本内容:最高
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
Text(
'${'lowest'.tr}: ${now["low"]}°', // 翻译键代表的中文文本内容:最低
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
],
),
],
),
],
),
Icon(
getWeatherIcon(now['text_day']),
size: 48.sp,
color: Colors.white,
),
],
),
SizedBox(height: 16.h),
Container(
height: 1,
color: Colors.white.withValues(alpha: isDarkMode ? 0.15 : 0.2),
),
SizedBox(height: 16.h),
Text(
'futureForecast'.tr, // 翻译键代表的中文文本内容:未来预报
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 8.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
days.length,
(index) {
String dateStr = days[index]['date'];
DateTime? date = DateUtil.getDateTime(dateStr);
IconData weatherIcon =
getWeatherIcon(days[index]['text_day']);
// 获取星期几的翻译键
String weekdayKey = _getWeekdayTranslationKey(date);
String weekday = weekdayKey.tr; // 翻译键代表的中文文本内容:对应的星期几
String temp = "${days[index]['high']}°";
return _buildForecastDay(
weekday,
weatherIcon,
temp,
);
},
),
),
SizedBox(height: 16.h),
_buildWeatherTip(context, now, isDarkMode),
],
),
),
],
);
}
/// 获取天气描述的翻译
String _getWeatherDescriptionTranslation(String weatherDescription) {
final description = weatherDescription.trim();
// 天气描述映射表
final Map<String, String> weatherMap = {
// 晴天类
'晴': 'sunny'.tr, // 翻译键代表的中文文本内容:晴
'少云': 'partlyCloudy'.tr, // 翻译键代表的中文文本内容:少云
'晴间多云': 'partlySunny'.tr, // 翻译键代表的中文文本内容:晴间多云
// 多云类
'多云': 'cloudy'.tr, // 翻译键代表的中文文本内容:多云
'阴': 'overcast'.tr, // 翻译键代表的中文文本内容:阴
// 雨天类
'阵雨': 'shower'.tr, // 翻译键代表的中文文本内容:阵雨
'雷阵雨': 'thundershower'.tr, // 翻译键代表的中文文本内容:雷阵雨
'小雨': 'lightRain'.tr, // 翻译键代表的中文文本内容:小雨
'中雨': 'moderateRain'.tr, // 翻译键代表的中文文本内容:中雨
'大雨': 'heavyRain'.tr, // 翻译键代表的中文文本内容:大雨
'暴雨': 'rainstorm'.tr, // 翻译键代表的中文文本内容:暴雨
// 雪天类
'阵雪': 'snowShower'.tr, // 翻译键代表的中文文本内容:阵雪
'小雪': 'lightSnow'.tr, // 翻译键代表的中文文本内容:小雪
'中雪': 'moderateSnow'.tr, // 翻译键代表的中文文本内容:中雪
'大雪': 'heavySnow'.tr, // 翻译键代表的中文文本内容:大雪
'暴雪': 'blizzard'.tr, // 翻译键代表的中文文本内容:暴雪
// 雾霾类
'雾': 'fog'.tr, // 翻译键代表的中文文本内容:雾
'霾': 'haze'.tr, // 翻译键代表的中文文本内容:霾
// 风类
'大风': 'strongWind'.tr, // 翻译键代表的中文文本内容:大风
};
// 返回翻译后的描述,如果没有找到对应翻译则返回原文
return weatherMap[description] ?? description;
}
/// 获取星期几的翻译键
String _getWeekdayTranslationKey(DateTime? date) {
if (date == null) return 'unknown';
switch (date.weekday) {
case 1:
return 'monday'; // 翻译键代表的中文文本内容:周一
case 2:
return 'tuesday'; // 翻译键代表的中文文本内容:周二
case 3:
return 'wednesday'; // 翻译键代表的中文文本内容:周三
case 4:
return 'thursday'; // 翻译键代表的中文文本内容:周四
case 5:
return 'friday'; // 翻译键代表的中文文本内容:周五
case 6:
return 'saturday'; // 翻译键代表的中文文本内容:周六
case 7:
return 'sunday'; // 翻译键代表的中文文本内容:周日
default:
return 'unknown';
}
}
/// 构建智能天气提示
Widget _buildWeatherTip(
BuildContext context, Map<String, dynamic> now, bool isDarkMode) {
final weatherTip = _generateWeatherTip(now);
return Container(
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.white.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
children: [
Icon(
weatherTip.icon,
size: 16.sp,
color: Colors.white,
),
SizedBox(width: 8.w),
Flexible(
child: Text(
weatherTip.message,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
),
),
),
],
),
);
}
/// 天气提示数据类
class WeatherTip {
final IconData icon;
final String message;
const WeatherTip({required this.icon, required this.message});
}
/// 根据天气数据生成智能提示
WeatherTip _generateWeatherTip(Map<String, dynamic> now) {
final dayWeather = (now['dayweather'] as String? ?? '').trim();
final dayTemp = int.tryParse(now['daytemp']?.toString() ?? '0') ?? 0;
final nightTemp = int.tryParse(now['nighttemp']?.toString() ?? '0') ?? 0;
// 高温提示 (>= 35°C)
if (dayTemp >= 35) {
return WeatherTip(
icon: Icons.local_fire_department,
message: 'hotWeatherTip'.tr, // 翻译键代表的中文文本内容:今日高温炎热,请注意防暑降温,多喝水避免中暑
);
}
// 低温提示 (<= 5°C)
if (dayTemp <= 5) {
return WeatherTip(
icon: Icons.ac_unit,
message: 'coldWeatherTip'.tr, // 翻译键代表的中文文本内容:今日气温较低,外出请注意保暖,小心感冒
);
}
// 雨天提示
if (dayWeather.contains('雨') ||
['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨']
.any((rain) => dayWeather.contains(rain))) {
return WeatherTip(
icon: Icons.umbrella,
message: 'rainyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雨,外出请携带雨具,注意出行安全
);
}
// 雪天提示
if (dayWeather.contains('雪') ||
['阵雪', '小雪', '中雪', '大雪', '暴雪'].any((snow) => dayWeather.contains(snow))) {
return WeatherTip(
icon: Icons.ac_unit,
message: 'snowyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雪,路面湿滑请注意安全,做好保暖措施
);
}
// 雾霾提示
if (dayWeather.contains('雾') ||
dayWeather.contains('霾') ||
['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => dayWeather.contains(fog))) {
return WeatherTip(
icon: Icons.blur_on,
message: 'foggyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雾霾,能见度较低,外出请佩戴口罩
);
}
// 大风提示
if (dayWeather.contains('风') ||
['大风', '强风', '疾风', '烈风'].any((wind) => dayWeather.contains(wind))) {
return WeatherTip(
icon: Icons.air,
message: 'windyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日风力较大,外出请注意安全,固定好物品
);
}
// 晴天紫外线提示 (温度 >= 25°C)
if (['晴', '少云', '晴间多云'].any((sunny) => dayWeather.contains(sunny)) &&
dayTemp >= 25) {
return WeatherTip(
icon: Icons.wb_sunny,
message: 'sunnyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日阳光充足紫外线较强,外出请做好防晒
);
}
// 温差大提示 (>= 10°C)
if ((dayTemp - nightTemp).abs() >= 10) {
return WeatherTip(
icon: Icons.thermostat,
message: 'temperatureDifferenceTip'.tr, // 翻译键代表的中文文本内容:今日昼夜温差较大,请适时增减衣物
);
}
// 默认舒适天气提示
return WeatherTip(
icon: Icons.wb_sunny_outlined,
message: 'comfortableWeatherTip'.tr, // 翻译键代表的中文文本内容:今日天气适宜,是外出活动的好时光
);
}
/// 构建单日天气预报
Widget _buildForecastDay(String day, IconData icon, String temp) {
return Column(
children: [
Text(
day,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
SizedBox(height: 4.h),
Icon(
icon,
size: 20.sp,
color: Colors.white,
),
SizedBox(height: 4.h),
Text(
temp,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
);
}
/// 获取天气图标
IconData getWeatherIcon(String description) {
description = description.trim();
if (["晴", "少云", "晴间多云"].contains(description)) {
return Icons.wb_sunny;
}
if (["多云", "阴"].contains(description)) {
return Icons.cloud;
}
if (description.contains("雾") ||
["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"]
.contains(description)) {
return Icons.blur_on;
}
if (description.contains("风") ||
[
"有风",
"平静",
"微风",
"和风",
"清风",
"强风/劲风",
"疾风",
"大风",
"烈风",
"风暴",
"狂爆风",
"飓风",
"热带风暴"
].contains(description)) {
return Icons.air;
}
if (description.contains("雨") ||
[
"阵雨",
"雷阵雨",
"雷阵雨并伴有冰雹",
"小雨",
"中雨",
"大雨",
"暴雨",
"大暴雨",
"特大暴雨",
"强阵雨",
"强雷阵雨",
"极端降雨",
"毛毛雨/细雨",
"雨",
"小雨-中雨",
"中雨-大雨",
"大雨-暴雨",
"暴雨-大暴雨",
"大暴雨-特大暴雨"
].contains(description)) {
return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain
}
if (description.contains("雪") ||
[
"雨雪天气",
"雨夹雪",
"阵雨夹雪",
"冻雨",
"雪",
"阵雪",
"小雪",
"中雪",
"大雪",
"暴雪",
"小雪-中雪",
"中雪-大雪",
"大雪-暴雪"
].contains(description)) {
return Icons.ac_unit;
}
if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) {
return Icons.filter_drama;
}
if (["龙卷风"].contains(description)) {
return Icons.waves;
}
if (["热"].contains(description)) {
return Icons.local_fire_department;
}
if (["冷"].contains(description)) {
return Icons.ac_unit;
}
// 默认或未知天气
return Icons.help_outline;
}
/// 根据天气类型获取背景渐变颜色
LinearGradient _getWeatherGradient(String weather, bool isDarkMode) {
final weatherType = weather.trim();
// 晴天 - 温暖的橙黄色
if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色
: [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色
);
}
// 多云/阴天 - 灰蓝色
if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝
: [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色
);
}
// 雨天 - 深蓝色
if (weatherType.contains('雨') ||
['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨']
.any((rain) => weatherType.contains(rain))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色
: [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色
);
}
// 雪天 - 冷色调蓝白色
if (weatherType.contains('雪') ||
['阵雪', '小雪', '中雪', '大雪', '暴雪']
.any((snow) => weatherType.contains(snow))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝
: [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色
);
}
// 雾霾天 - 暗黄灰色
if (weatherType.contains('雾') ||
weatherType.contains('霾') ||
['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色
: [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色
);
}
// 大风天 - 青绿色
if (weatherType.contains('风') ||
['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色
: [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色
);
}
// 默认 - 原来的蓝色
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1E40AF), Color(0xFF1E3A8A)]
: [Color(0xFF60A5FA), Color(0xFF3B82F6)],
);
}

562
apps/client/lib/modules/agent/views/weather_card.dart

@ -1,562 +0,0 @@
import 'package:common_utils/common_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
/// 构建天气内容卡片
Widget buildWeatherContent(BuildContext context, String city,
Map<String, dynamic> now, List<Map<String, dynamic>> days) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'weatherQuery'.tr, // 翻译键代表的中文文本内容:查询天气情况:
style: TextStyle(
color:
isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14.sp,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.h),
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
gradient: _getWeatherGradient(now['dayweather'] ?? '', isDarkMode),
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.3 : 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"${city} ${now["date"]}",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 4.w),
Text(
_getWeatherDescriptionTranslation(now["dayweather"]),
style: TextStyle(
fontSize: 14.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
SizedBox(height: 8.h),
Row(
children: [
Text(
"${now["daytemp"]}°",
style: TextStyle(
fontSize: 36.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(width: 8.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${'highest'.tr}: ${now["daytemp"]}°', // 翻译键代表的中文文本内容:最高
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
Text(
'${'lowest'.tr}: ${now["nighttemp"]}°', // 翻译键代表的中文文本内容:最低
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
],
),
],
),
],
),
Icon(
getWeatherIcon(now['dayweather']),
size: 48.sp,
color: Colors.white,
),
],
),
SizedBox(height: 16.h),
Container(
height: 1,
color: Colors.white.withValues(alpha: isDarkMode ? 0.15 : 0.2),
),
SizedBox(height: 16.h),
Text(
'futureForecast'.tr, // 翻译键代表的中文文本内容:未来预报
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 8.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
days.length,
(index) {
String dateStr = days[index]['date'];
DateTime? date = DateUtil.getDateTime(dateStr);
IconData weatherIcon =
getWeatherIcon(days[index]['dayweather']);
// 获取星期几的翻译键
String weekdayKey = _getWeekdayTranslationKey(date);
String weekday = weekdayKey.tr; // 翻译键代表的中文文本内容:对应的星期几
String temp = "${days[index]['daytemp']}°";
return _buildForecastDay(
weekday,
weatherIcon,
temp,
);
},
),
),
SizedBox(height: 16.h),
_buildWeatherTip(context, now, isDarkMode),
],
),
),
],
);
}
/// 获取天气描述的翻译
String _getWeatherDescriptionTranslation(String weatherDescription) {
final description = weatherDescription.trim();
// 天气描述映射表
final Map<String, String> weatherMap = {
// 晴天类
'晴': 'sunny'.tr, // 翻译键代表的中文文本内容:晴
'少云': 'partlyCloudy'.tr, // 翻译键代表的中文文本内容:少云
'晴间多云': 'partlySunny'.tr, // 翻译键代表的中文文本内容:晴间多云
// 多云类
'多云': 'cloudy'.tr, // 翻译键代表的中文文本内容:多云
'阴': 'overcast'.tr, // 翻译键代表的中文文本内容:阴
// 雨天类
'阵雨': 'shower'.tr, // 翻译键代表的中文文本内容:阵雨
'雷阵雨': 'thundershower'.tr, // 翻译键代表的中文文本内容:雷阵雨
'小雨': 'lightRain'.tr, // 翻译键代表的中文文本内容:小雨
'中雨': 'moderateRain'.tr, // 翻译键代表的中文文本内容:中雨
'大雨': 'heavyRain'.tr, // 翻译键代表的中文文本内容:大雨
'暴雨': 'rainstorm'.tr, // 翻译键代表的中文文本内容:暴雨
// 雪天类
'阵雪': 'snowShower'.tr, // 翻译键代表的中文文本内容:阵雪
'小雪': 'lightSnow'.tr, // 翻译键代表的中文文本内容:小雪
'中雪': 'moderateSnow'.tr, // 翻译键代表的中文文本内容:中雪
'大雪': 'heavySnow'.tr, // 翻译键代表的中文文本内容:大雪
'暴雪': 'blizzard'.tr, // 翻译键代表的中文文本内容:暴雪
// 雾霾类
'雾': 'fog'.tr, // 翻译键代表的中文文本内容:雾
'霾': 'haze'.tr, // 翻译键代表的中文文本内容:霾
// 风类
'大风': 'strongWind'.tr, // 翻译键代表的中文文本内容:大风
};
// 返回翻译后的描述,如果没有找到对应翻译则返回原文
return weatherMap[description] ?? description;
}
/// 获取星期几的翻译键
String _getWeekdayTranslationKey(DateTime? date) {
if (date == null) return 'unknown';
switch (date.weekday) {
case 1:
return 'monday'; // 翻译键代表的中文文本内容:周一
case 2:
return 'tuesday'; // 翻译键代表的中文文本内容:周二
case 3:
return 'wednesday'; // 翻译键代表的中文文本内容:周三
case 4:
return 'thursday'; // 翻译键代表的中文文本内容:周四
case 5:
return 'friday'; // 翻译键代表的中文文本内容:周五
case 6:
return 'saturday'; // 翻译键代表的中文文本内容:周六
case 7:
return 'sunday'; // 翻译键代表的中文文本内容:周日
default:
return 'unknown';
}
}
/// 构建智能天气提示
Widget _buildWeatherTip(
BuildContext context, Map<String, dynamic> now, bool isDarkMode) {
final weatherTip = _generateWeatherTip(now);
return Container(
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.white.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
children: [
Icon(
weatherTip.icon,
size: 16.sp,
color: Colors.white,
),
SizedBox(width: 8.w),
Flexible(
child: Text(
weatherTip.message,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
),
),
),
],
),
);
}
/// 天气提示数据类
class WeatherTip {
final IconData icon;
final String message;
const WeatherTip({required this.icon, required this.message});
}
/// 根据天气数据生成智能提示
WeatherTip _generateWeatherTip(Map<String, dynamic> now) {
final dayWeather = (now['dayweather'] as String? ?? '').trim();
final dayTemp = int.tryParse(now['daytemp']?.toString() ?? '0') ?? 0;
final nightTemp = int.tryParse(now['nighttemp']?.toString() ?? '0') ?? 0;
// 高温提示 (>= 35°C)
if (dayTemp >= 35) {
return WeatherTip(
icon: Icons.local_fire_department,
message: 'hotWeatherTip'.tr, // 翻译键代表的中文文本内容:今日高温炎热,请注意防暑降温,多喝水避免中暑
);
}
// 低温提示 (<= 5°C)
if (dayTemp <= 5) {
return WeatherTip(
icon: Icons.ac_unit,
message: 'coldWeatherTip'.tr, // 翻译键代表的中文文本内容:今日气温较低,外出请注意保暖,小心感冒
);
}
// 雨天提示
if (dayWeather.contains('雨') ||
['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨']
.any((rain) => dayWeather.contains(rain))) {
return WeatherTip(
icon: Icons.umbrella,
message: 'rainyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雨,外出请携带雨具,注意出行安全
);
}
// 雪天提示
if (dayWeather.contains('雪') ||
['阵雪', '小雪', '中雪', '大雪', '暴雪'].any((snow) => dayWeather.contains(snow))) {
return WeatherTip(
icon: Icons.ac_unit,
message: 'snowyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雪,路面湿滑请注意安全,做好保暖措施
);
}
// 雾霾提示
if (dayWeather.contains('雾') ||
dayWeather.contains('霾') ||
['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => dayWeather.contains(fog))) {
return WeatherTip(
icon: Icons.blur_on,
message: 'foggyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雾霾,能见度较低,外出请佩戴口罩
);
}
// 大风提示
if (dayWeather.contains('风') ||
['大风', '强风', '疾风', '烈风'].any((wind) => dayWeather.contains(wind))) {
return WeatherTip(
icon: Icons.air,
message: 'windyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日风力较大,外出请注意安全,固定好物品
);
}
// 晴天紫外线提示 (温度 >= 25°C)
if (['晴', '少云', '晴间多云'].any((sunny) => dayWeather.contains(sunny)) &&
dayTemp >= 25) {
return WeatherTip(
icon: Icons.wb_sunny,
message: 'sunnyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日阳光充足紫外线较强,外出请做好防晒
);
}
// 温差大提示 (>= 10°C)
if ((dayTemp - nightTemp).abs() >= 10) {
return WeatherTip(
icon: Icons.thermostat,
message: 'temperatureDifferenceTip'.tr, // 翻译键代表的中文文本内容:今日昼夜温差较大,请适时增减衣物
);
}
// 默认舒适天气提示
return WeatherTip(
icon: Icons.wb_sunny_outlined,
message: 'comfortableWeatherTip'.tr, // 翻译键代表的中文文本内容:今日天气适宜,是外出活动的好时光
);
}
/// 构建单日天气预报
Widget _buildForecastDay(String day, IconData icon, String temp) {
return Column(
children: [
Text(
day,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
SizedBox(height: 4.h),
Icon(
icon,
size: 20.sp,
color: Colors.white,
),
SizedBox(height: 4.h),
Text(
temp,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
);
}
/// 获取天气图标
IconData getWeatherIcon(String description) {
description = description.trim();
if (["晴", "少云", "晴间多云"].contains(description)) {
return Icons.wb_sunny;
}
if (["多云", "阴"].contains(description)) {
return Icons.cloud;
}
if (description.contains("雾") ||
["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"]
.contains(description)) {
return Icons.blur_on;
}
if (description.contains("风") ||
[
"有风",
"平静",
"微风",
"和风",
"清风",
"强风/劲风",
"疾风",
"大风",
"烈风",
"风暴",
"狂爆风",
"飓风",
"热带风暴"
].contains(description)) {
return Icons.air;
}
if (description.contains("雨") ||
[
"阵雨",
"雷阵雨",
"雷阵雨并伴有冰雹",
"小雨",
"中雨",
"大雨",
"暴雨",
"大暴雨",
"特大暴雨",
"强阵雨",
"强雷阵雨",
"极端降雨",
"毛毛雨/细雨",
"雨",
"小雨-中雨",
"中雨-大雨",
"大雨-暴雨",
"暴雨-大暴雨",
"大暴雨-特大暴雨"
].contains(description)) {
return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain
}
if (description.contains("雪") ||
[
"雨雪天气",
"雨夹雪",
"阵雨夹雪",
"冻雨",
"雪",
"阵雪",
"小雪",
"中雪",
"大雪",
"暴雪",
"小雪-中雪",
"中雪-大雪",
"大雪-暴雪"
].contains(description)) {
return Icons.ac_unit;
}
if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) {
return Icons.filter_drama;
}
if (["龙卷风"].contains(description)) {
return Icons.waves;
}
if (["热"].contains(description)) {
return Icons.local_fire_department;
}
if (["冷"].contains(description)) {
return Icons.ac_unit;
}
// 默认或未知天气
return Icons.help_outline;
}
/// 根据天气类型获取背景渐变颜色
LinearGradient _getWeatherGradient(String weather, bool isDarkMode) {
final weatherType = weather.trim();
// 晴天 - 温暖的橙黄色
if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色
: [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色
);
}
// 多云/阴天 - 灰蓝色
if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝
: [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色
);
}
// 雨天 - 深蓝色
if (weatherType.contains('雨') ||
['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨']
.any((rain) => weatherType.contains(rain))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色
: [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色
);
}
// 雪天 - 冷色调蓝白色
if (weatherType.contains('雪') ||
['阵雪', '小雪', '中雪', '大雪', '暴雪']
.any((snow) => weatherType.contains(snow))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝
: [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色
);
}
// 雾霾天 - 暗黄灰色
if (weatherType.contains('雾') ||
weatherType.contains('霾') ||
['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色
: [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色
);
}
// 大风天 - 青绿色
if (weatherType.contains('风') ||
['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色
: [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色
);
}
// 默认 - 原来的蓝色
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1E40AF), Color(0xFF1E3A8A)]
: [Color(0xFF60A5FA), Color(0xFF3B82F6)],
);
}

481
apps/client/lib/modules/agent/views/weather_hefeng.dart

@ -1,481 +0,0 @@
import 'package:common_utils/common_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
/// 构建天气内容卡片
Widget hefengWeatherContent(BuildContext context, String city,
Map<String, dynamic> now, List<Map<String, dynamic>> days) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'weatherQuery'.tr, // 翻译键代表的中文文本内容:查询天气情况:
style: TextStyle(
color:
isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14.sp,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.h),
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
gradient: _getWeatherGradient(now['textDay'] ?? '', isDarkMode),
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.3 : 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"${city} ${now["fxDate"]}",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 4.w),
Text(
_getWeatherDescriptionTranslation(now["textDay"]),
style: TextStyle(
fontSize: 14.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
SizedBox(height: 8.h),
Row(
children: [
Text(
"${now["tempMax"]}°",
style: TextStyle(
fontSize: 36.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(width: 8.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${'highest'.tr}: ${now["tempMax"]}°', // 翻译键代表的中文文本内容:最高
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
Text(
'${'lowest'.tr}: ${now["tempMin"]}°', // 翻译键代表的中文文本内容:最低
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
],
),
],
),
],
),
getWeatherSvgIcon(
now['iconDay'],
size: 48.sp,
),
],
),
SizedBox(height: 16.h),
Container(
height: 1,
color: Colors.white.withValues(alpha: isDarkMode ? 0.15 : 0.2),
),
SizedBox(height: 16.h),
Text(
'futureForecast'.tr, // 翻译键代表的中文文本内容:未来预报
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 8.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(
days.length,
(index) {
String dateStr = days[index]['fxDate'];
DateTime? date = DateUtil.getDateTime(dateStr);
Widget weatherIcon =
getWeatherSvgIcon(days[index]['iconDay'], size: 20.sp);
// 获取星期几的翻译键
String weekdayKey = _getWeekdayTranslationKey(date);
String weekday = weekdayKey.tr; // 翻译键代表的中文文本内容:对应的星期几
String temp = "${days[index]['tempMax']}°";
return _buildForecastDay(
weekday,
weatherIcon,
temp,
);
},
),
),
SizedBox(height: 16.h),
_hefengWeatherTip(context, now, isDarkMode),
],
),
),
],
);
}
/// 获取天气描述的翻译
String _getWeatherDescriptionTranslation(String weatherDescription) {
final description = weatherDescription.trim();
// 天气描述映射表
final Map<String, String> weatherMap = {
// 晴天类
'晴': 'sunny'.tr, // 翻译键代表的中文文本内容:晴
'少云': 'partlyCloudy'.tr, // 翻译键代表的中文文本内容:少云
'晴间多云': 'partlySunny'.tr, // 翻译键代表的中文文本内容:晴间多云
// 多云类
'多云': 'cloudy'.tr, // 翻译键代表的中文文本内容:多云
'阴': 'overcast'.tr, // 翻译键代表的中文文本内容:阴
// 雨天类
'阵雨': 'shower'.tr, // 翻译键代表的中文文本内容:阵雨
'雷阵雨': 'thundershower'.tr, // 翻译键代表的中文文本内容:雷阵雨
'小雨': 'lightRain'.tr, // 翻译键代表的中文文本内容:小雨
'中雨': 'moderateRain'.tr, // 翻译键代表的中文文本内容:中雨
'大雨': 'heavyRain'.tr, // 翻译键代表的中文文本内容:大雨
'暴雨': 'rainstorm'.tr, // 翻译键代表的中文文本内容:暴雨
// 雪天类
'阵雪': 'snowShower'.tr, // 翻译键代表的中文文本内容:阵雪
'小雪': 'lightSnow'.tr, // 翻译键代表的中文文本内容:小雪
'中雪': 'moderateSnow'.tr, // 翻译键代表的中文文本内容:中雪
'大雪': 'heavySnow'.tr, // 翻译键代表的中文文本内容:大雪
'暴雪': 'blizzard'.tr, // 翻译键代表的中文文本内容:暴雪
// 雾霾类
'雾': 'fog'.tr, // 翻译键代表的中文文本内容:雾
'霾': 'haze'.tr, // 翻译键代表的中文文本内容:霾
// 风类
'大风': 'strongWind'.tr, // 翻译键代表的中文文本内容:大风
};
// 返回翻译后的描述,如果没有找到对应翻译则返回原文
return weatherMap[description] ?? description;
}
/// 获取星期几的翻译键
String _getWeekdayTranslationKey(DateTime? date) {
if (date == null) return 'unknown';
switch (date.weekday) {
case 1:
return 'monday'; // 翻译键代表的中文文本内容:周一
case 2:
return 'tuesday'; // 翻译键代表的中文文本内容:周二
case 3:
return 'wednesday'; // 翻译键代表的中文文本内容:周三
case 4:
return 'thursday'; // 翻译键代表的中文文本内容:周四
case 5:
return 'friday'; // 翻译键代表的中文文本内容:周五
case 6:
return 'saturday'; // 翻译键代表的中文文本内容:周六
case 7:
return 'sunday'; // 翻译键代表的中文文本内容:周日
default:
return 'unknown';
}
}
/// 构建智能天气提示
Widget _hefengWeatherTip(
BuildContext context, Map<String, dynamic> now, bool isDarkMode) {
final weatherTip = _generateWeatherTip(now);
return Container(
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.white.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
children: [
Icon(
weatherTip.icon,
size: 16.sp,
color: Colors.white,
),
SizedBox(width: 8.w),
Flexible(
child: Text(
weatherTip.message,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
),
),
),
],
),
);
}
/// 天气提示数据类
class WeatherTip {
final IconData icon;
final String message;
const WeatherTip({required this.icon, required this.message});
}
/// 根据天气数据生成智能提示
WeatherTip _generateWeatherTip(Map<String, dynamic> now) {
final dayWeather = (now['textDay'] as String? ?? '').trim();
final dayTemp = int.tryParse(now['tempMax']?.toString() ?? '0') ?? 0;
final nightTemp = int.tryParse(now['tempMin']?.toString() ?? '0') ?? 0;
// 高温提示 (>= 35°C)
if (dayTemp >= 35) {
return WeatherTip(
icon: Icons.local_fire_department,
message: 'hotWeatherTip'.tr, // 翻译键代表的中文文本内容:今日高温炎热,请注意防暑降温,多喝水避免中暑
);
}
// 低温提示 (<= 5°C)
if (dayTemp <= 5) {
return WeatherTip(
icon: Icons.ac_unit,
message: 'coldWeatherTip'.tr, // 翻译键代表的中文文本内容:今日气温较低,外出请注意保暖,小心感冒
);
}
// 雨天提示
if (dayWeather.contains('雨') ||
['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨']
.any((rain) => dayWeather.contains(rain))) {
return WeatherTip(
icon: Icons.umbrella,
message: 'rainyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雨,外出请携带雨具,注意出行安全
);
}
// 雪天提示
if (dayWeather.contains('雪') ||
['阵雪', '小雪', '中雪', '大雪', '暴雪'].any((snow) => dayWeather.contains(snow))) {
return WeatherTip(
icon: Icons.ac_unit,
message: 'snowyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雪,路面湿滑请注意安全,做好保暖措施
);
}
// 雾霾提示
if (dayWeather.contains('雾') ||
dayWeather.contains('霾') ||
['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => dayWeather.contains(fog))) {
return WeatherTip(
icon: Icons.blur_on,
message: 'foggyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日有雾霾,能见度较低,外出请佩戴口罩
);
}
// 大风提示
if (dayWeather.contains('风') ||
['大风', '强风', '疾风', '烈风'].any((wind) => dayWeather.contains(wind))) {
return WeatherTip(
icon: Icons.air,
message: 'windyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日风力较大,外出请注意安全,固定好物品
);
}
// 晴天紫外线提示 (温度 >= 25°C)
if (['晴', '少云', '晴间多云'].any((sunny) => dayWeather.contains(sunny)) &&
dayTemp >= 25) {
return WeatherTip(
icon: Icons.wb_sunny,
message: 'sunnyWeatherTip'.tr, // 翻译键代表的中文文本内容:今日阳光充足紫外线较强,外出请做好防晒
);
}
// 温差大提示 (>= 10°C)
if ((dayTemp - nightTemp).abs() >= 10) {
return WeatherTip(
icon: Icons.thermostat,
message: 'temperatureDifferenceTip'.tr, // 翻译键代表的中文文本内容:今日昼夜温差较大,请适时增减衣物
);
}
// 默认舒适天气提示
return WeatherTip(
icon: Icons.wb_sunny_outlined,
message: 'comfortableWeatherTip'.tr, // 翻译键代表的中文文本内容:今日天气适宜,是外出活动的好时光
);
}
/// 构建单日天气预报
Widget _buildForecastDay(String day, Widget icon, String temp) {
return Column(
children: [
Text(
day,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
),
),
SizedBox(height: 4.h),
icon,
SizedBox(height: 4.h),
Text(
temp,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
);
}
/// 获取天气 SVG 图标 Widget
Widget getWeatherSvgIcon(String mappedCode, {double size = 24.0}) {
return _getWeatherSvgByCode(mappedCode, size: size);
}
/// 根据和风天气代码获取 SVG 图标
Widget _getWeatherSvgByCode(String code, {double size = 24.0}) {
// 检查 SVG 文件是否存在,如果不存在则使用默认图标
final svgPath = 'assets/weather/$code.svg';
return SvgPicture.asset(
svgPath,
width: size,
height: size,
fit: BoxFit.contain,
colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
// 如果 SVG 加载失败,显示默认图标
placeholderBuilder: (context) => Icon(
Icons.help_outline,
size: size,
),
);
}
/// 根据天气类型获取背景渐变颜色
LinearGradient _getWeatherGradient(String weather, bool isDarkMode) {
final weatherType = weather.trim();
// 晴天 - 温暖的橙黄色
if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色
: [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色
);
}
// 多云/阴天 - 灰蓝色
if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝
: [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色
);
}
// 雨天 - 深蓝色
if (weatherType.contains('雨') ||
['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨']
.any((rain) => weatherType.contains(rain))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色
: [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色
);
}
// 雪天 - 冷色调蓝白色
if (weatherType.contains('雪') ||
['阵雪', '小雪', '中雪', '大雪', '暴雪']
.any((snow) => weatherType.contains(snow))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝
: [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色
);
}
// 雾霾天 - 暗黄灰色
if (weatherType.contains('雾') ||
weatherType.contains('霾') ||
['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色
: [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色
);
}
// 大风天 - 青绿色
if (weatherType.contains('风') ||
['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色
: [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色
);
}
// 默认 - 原来的蓝色
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? [Color(0xFF1E40AF), Color(0xFF1E3A8A)]
: [Color(0xFF60A5FA), Color(0xFF3B82F6)],
);
}

114
apps/client/lib/modules/agent/widgets/circular_progress_button.dart

@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
/// 环形进度条播放按钮组件
class CircularProgressButton extends StatelessWidget {
final bool isPlaying;
final double progress; // 0.0 - 1.0
final VoidCallback onPlayPause;
final Function(double)? onSeek; // 点击进度条跳转回调
final double size;
final Color primaryColor;
final Color backgroundColor;
final double strokeWidth;
const CircularProgressButton({
Key? key,
required this.isPlaying,
required this.progress,
required this.onPlayPause,
this.onSeek,
this.size = 36.0,
required this.primaryColor,
this.backgroundColor = Colors.grey,
this.strokeWidth = 2.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// 只保留播放/暂停功能,移除进度条点击交互
onPlayPause();
},
child: SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// 背景圆环
CustomPaint(
size: Size(size, size),
painter: _CircularProgressPainter(
progress: 1.0,
color: backgroundColor.withValues(alpha: 0.3),
strokeWidth: strokeWidth,
),
),
// 进度圆环
CustomPaint(
size: Size(size, size),
painter: _CircularProgressPainter(
progress: progress,
color: primaryColor,
strokeWidth: strokeWidth,
),
),
// 播放/暂停按钮
Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: primaryColor,
size: size * 0.5,
),
],
),
),
);
}
}
/// 环形进度条绘制器
class _CircularProgressPainter extends CustomPainter {
final double progress;
final Color color;
final double strokeWidth;
_CircularProgressPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width / 2) - strokeWidth / 2;
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
// 绘制圆弧,从顶部开始 (-π/2),顺时针绘制
const startAngle = -math.pi / 2;
final sweepAngle = 2 * math.pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _CircularProgressPainter ||
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
}

191
apps/client/lib/modules/agent/widgets/full_screen_editor.dart

@ -1,191 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/agent_controller.dart';
/// 底部弹出式文本编辑器组件
class FullScreenEditor extends StatefulWidget {
const FullScreenEditor({super.key});
@override
State<FullScreenEditor> createState() => _FullScreenEditorState();
}
class _FullScreenEditorState extends State<FullScreenEditor> {
late TextEditingController _textController;
late FocusNode _focusNode;
final AgentController controller = AgentController.to;
@override
void initState() {
super.initState();
// 使用与主界面相同的文本控制器,保持内容同步
_textController = controller.textController;
_focusNode = FocusNode();
// 延迟聚焦,确保界面完全加载后再聚焦
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final primaryColor = Theme.of(context).primaryColor;
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; // 获取键盘高度
return Container(
height: MediaQuery.of(context).size.height * 0.95, // 占屏幕高度的90%
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20.r),
topRight: Radius.circular(20.r),
),
),
child: Stack(
children: [
// 主要内容区域
Column(
children: [
// 主要编辑区域
Expanded(
child: Container(
padding: EdgeInsets.fromLTRB(16.w, 60.w, 16.w, 16.w),
child: TextField(
controller: _textController,
focusNode: _focusNode,
maxLines: null,
expands: true,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
style: TextStyle(
fontSize: 16.sp,
color: isDarkMode ? Colors.white : Colors.black87,
height: 1.5,
),
decoration: InputDecoration(
hintText: 'fullScreenEditorHint'.tr, // 在这里输入你的消息...
hintStyle: TextStyle(
color: isDarkMode ? Colors.white54 : Colors.grey[500],
fontSize: 16.sp,
),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
),
// 底部操作区域
Container(
margin:
EdgeInsets.only(bottom: keyboardHeight), // 关键修改:添加键盘高度的边距
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[50],
border: Border(
top: BorderSide(
color: isDarkMode ? Colors.grey[700]! : Colors.grey[200]!,
width: 1,
),
),
),
child: Row(
children: [
// 字符计数
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
return Text(
'characterCount'.tr.replaceAll('{count}',
'${value.text.length}'), // ${value.text.length} 字符
style: TextStyle(
color: isDarkMode
? Colors.white60
: Colors.grey[600],
fontSize: 14.sp,
),
);
},
),
),
// 发送按钮 - 保持与主界面一致的样式
Obx(() {
final hasText = controller.hasTextInput.value;
return GestureDetector(
onTap: hasText
? () async {
await controller.sendMessageFromFullScreen();
if (context.mounted) {
Navigator.of(context).pop();
}
}
: null,
child: Container(
width: 44.w,
height: 44.h,
decoration: BoxDecoration(
color: hasText
? primaryColor
: (isDarkMode
? Colors.grey[800]
: Colors.grey[300]),
borderRadius: BorderRadius.circular(22.r),
),
child: Icon(
Icons.send,
color: hasText
? Colors.white
: (isDarkMode
? Colors.grey[600]
: Colors.grey[500]),
size: 22.sp,
),
),
);
}),
],
),
),
],
),
// 右上角关闭按钮
Positioned(
top: 16.h,
right: 16.w,
child: GestureDetector(
onTap: () {
controller.closeFullScreenEdit();
Navigator.of(context).pop();
},
child: Container(
width: 32.w,
height: 32.h,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
borderRadius: BorderRadius.circular(16.r),
),
child: Icon(
Icons.close,
color: isDarkMode ? Colors.white : Colors.black87,
size: 20.sp,
),
),
),
),
],
),
);
}
}

155
apps/client/lib/modules/agent/widgets/sound_bar_widget.dart

@ -1,155 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/agent_controller.dart';
/// 声音柱动画组件 (已优化)
/// 用于显示语音输入时的音频波形效果
class SoundBarWidget extends StatefulWidget {
final double delay;
final bool isActive;
const SoundBarWidget({
Key? key,
required this.delay,
required this.isActive,
}) : super(key: key);
@override
State<SoundBarWidget> createState() => _SoundBarWidgetState();
}
class _SoundBarWidgetState extends State<SoundBarWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_animation = Tween<double>(begin: 3, end: 18).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
// 移除了 .addListener(() { setState(() {}); })
_startOrStopAnimation();
}
@override
void didUpdateWidget(SoundBarWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive != oldWidget.isActive) {
_startOrStopAnimation();
}
}
/// 开始或停止动画
void _startOrStopAnimation() {
// 确保组件仍然挂载在树上
if (!mounted) return;
if (widget.isActive) {
Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () {
if (mounted && widget.isActive) {
_controller.repeat(reverse: true);
}
});
} else {
// 停止并平滑地过渡到初始状态
_controller.stop();
_controller.animateTo(0.0,
duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final primaryColor = Theme.of(context).primaryColor;
// 使用 AnimatedBuilder 来优化性能
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 2.5,
// 当动画不活跃时,高度固定为3;活跃时使用动画值
height: widget.isActive ? _animation.value : 3,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: widget.isActive
? primaryColor.withOpacity(0.8)
: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(1.5),
),
);
},
);
}
}
/// 声音柱组合组件 (无需改动)
/// 包含多个声音柱的组合显示
class SoundBarsWidget extends StatelessWidget {
final bool isActive;
final int barCount;
const SoundBarsWidget({
Key? key,
required this.isActive,
this.barCount = 10,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(barCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: SoundBarWidget(
delay: index * 0.1,
isActive: isActive,
),
);
}),
);
}
}
/// 带控制器集成的声音柱组件 (无需改动)
/// 自动从AgentController获取状态
class AgentSoundBarsWidget extends StatelessWidget {
final int barCount;
const AgentSoundBarsWidget({
Key? key,
this.barCount = 10,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
final controller = Get.find<AgentController>();
final shouldShowAnimation =
controller.isListening.value || controller.isPushToTalkActive.value;
return SoundBarsWidget(
isActive: shouldShowAnimation,
barCount: barCount,
);
});
}
}

9
apps/client/lib/modules/ble/bindings/ble_binding.dart

@ -1,9 +0,0 @@
import 'package:get/get.dart';
import '../controllers/ble_controller.dart';
class BleBinding extends Bindings {
@override
void dependencies() {
Get.put<BleController>(BleController());
}
}

267
apps/client/lib/modules/ble/controllers/ble_controller.dart

@ -1,267 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
import 'package:permission_handler/permission_handler.dart';
import '../../../core/utils/permission_util.dart';
class BleController extends GetxController {
// 扫描状态
final RxBool isScanning = false.obs;
final RxBool isBluetoothOn = false.obs;
final RxBool hasPermissions = false.obs;
// 发现的设备列表
final RxList<fbp.ScanResult> scanResults = <fbp.ScanResult>[].obs;
// 连接的设备
final Rx<fbp.BluetoothDevice?> connectedDevice =
Rx<fbp.BluetoothDevice?>(null);
// 错误信息
final RxString errorMessage = ''.obs;
@override
void onInit() {
super.onInit();
_initBluetooth();
}
@override
void onClose() {
stopScan();
super.onClose();
}
// 初始化蓝牙
Future<void> _initBluetooth() async {
try {
// 先检查权限
await _checkPermissions();
// 检查蓝牙适配器状态
fbp.FlutterBluePlus.adapterState.listen((state) {
isBluetoothOn.value = state == fbp.BluetoothAdapterState.on;
if (state == fbp.BluetoothAdapterState.on) {
errorMessage.value = '';
} else {
errorMessage.value = 'pleaseEnableBluetooth'.tr;
stopScan();
}
});
// 监听扫描结果
fbp.FlutterBluePlus.scanResults.listen((results) {
scanResults.value = results;
});
// 监听扫描状态
fbp.FlutterBluePlus.isScanning.listen((scanning) {
isScanning.value = scanning;
});
} catch (e) {
errorMessage.value = 'bleInitFailedWithError'.trParams({'error': e.toString()});
}
}
// 检查权限(只查状态,绝不发起申请)
//
// 合规(场景 1/2):进页面就自动弹权限框是明确禁止的。这里原来虽然叫
// "不请求",实际却调了 .request(),用户一进 BLE 页就被弹蓝牙+定位框,
// 拒绝之后每次重进页面还会再弹。现在只读状态,申请交给用户点"扫描"时触发。
Future<void> _checkPermissions() async {
try {
bool allGranted = true;
for (final p in _blePermissions) {
if (!await p.status.isGranted) {
allGranted = false;
break;
}
}
hasPermissions.value = allGranted;
if (!allGranted) {
errorMessage.value = 'needBluetoothAndLocationPermission'.tr;
}
} catch (e) {
print('检查权限失败: $e');
hasPermissions.value = false;
}
}
// 扫描需要的权限(成套,缺一个都扫不出设备)
static const List<Permission> _blePermissions = [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.location,
];
// 请求权限(仅由用户点击"扫描"触发)
//
// 合规(场景 1):拒绝之后不再弹任何框、不跳设置页——原来这里在检测到
// permanentlyDenied 时会立刻弹一个带"前往设置"的自定义框,正是要禁掉的行为。
// 现在统一走 PermissionUtil,拒绝即进入 48h 冷却,返回 false 后由 UI 置灰扫描按钮。
Future<bool> _requestPermissions() async {
try {
final allGranted = await PermissionUtil.instance.requestPermissions(
permissions: _blePermissions,
permissionName: 'bluetooth'.tr,
explanationText: 'needBluetoothAndLocationPermission'.tr,
);
hasPermissions.value = allGranted;
if (!allGranted) {
// 只做静态提示,不引导、不跳转
errorMessage.value = 'needBluetoothAndLocationPermission'.tr;
}
return allGranted;
} catch (e) {
print('请求权限失败: $e');
errorMessage.value = 'permissionRequestFailedWithError'.trParams({'error': e.toString()});
return false;
}
}
/// 权限被拒且仍在冷却期 —— 视图据此把扫描入口置灰
bool get isPermissionCoolingDown => _blePermissions
.any((p) => PermissionUtil.instance.isDenialCoolingDown(p));
// 显示蓝牙开启对话框
void _showBluetoothDialog() {
Get.dialog(
AlertDialog(
title: Text('needEnableBluetooth'.tr),
content: Text('enableBluetoothDialogContent'.tr),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () {
Get.back();
// 尝试开启蓝牙
fbp.FlutterBluePlus.turnOn();
},
child: Text('enableBluetooth'.tr),
),
],
),
);
}
// 开始扫描
Future<void> startScan() async {
try {
errorMessage.value = '';
// 检查权限
if (!hasPermissions.value) {
if (!await _requestPermissions()) {
return;
}
}
// 检查蓝牙状态
if (!isBluetoothOn.value) {
_showBluetoothDialog();
return;
}
// 清空之前的结果
scanResults.clear();
// 开始扫描
await fbp.FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidUsesFineLocation: true,
);
} catch (e) {
errorMessage.value = 'bleScanFailedWithError'.trParams({'error': e.toString()});
}
}
// 停止扫描
Future<void> stopScan() async {
try {
await fbp.FlutterBluePlus.stopScan();
} catch (e) {
print('停止扫描失败: $e');
}
}
// 连接设备
Future<void> connectToDevice(fbp.BluetoothDevice device) async {
try {
errorMessage.value = '';
// 停止扫描
await stopScan();
// 连接设备
await device.connect(timeout: const Duration(seconds: 15));
connectedDevice.value = device;
Get.snackbar(
'bleConnectionSuccess'.tr,
'bleConnectedToDevice'.trParams({'device': device.platformName.isNotEmpty ? device.platformName : 'unknownEquipment'.tr}),
backgroundColor: Colors.green,
colorText: Colors.white,
);
} catch (e) {
errorMessage.value = 'bleConnectionFailedWithError'.trParams({'error': e.toString()});
Get.snackbar(
'connectionFailed'.tr,
e.toString(),
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
// 断开连接
Future<void> disconnectDevice() async {
try {
if (connectedDevice.value != null) {
await connectedDevice.value!.disconnect();
connectedDevice.value = null;
Get.snackbar(
'bleDisconnected'.tr,
'bleDeviceDisconnected'.tr,
backgroundColor: Colors.orange,
colorText: Colors.white,
);
}
} catch (e) {
errorMessage.value = 'bleDisconnectFailedWithError'.trParams({'error': e.toString()});
}
}
// 获取设备名称
String getDeviceName(fbp.ScanResult result) {
if (result.device.platformName.isNotEmpty) {
return result.device.platformName;
}
if (result.advertisementData.localName.isNotEmpty) {
return result.advertisementData.localName;
}
return 'unknownEquipment'.tr;
}
// 获取信号强度描述
String getSignalStrengthText(int rssi) {
if (rssi >= -50) return 'signalStrong'.tr;
if (rssi >= -70) return 'signalMedium'.tr;
if (rssi >= -90) return 'signalWeak'.tr;
return 'signalVeryWeak'.tr;
}
// 获取信号强度图标
IconData getSignalStrengthIcon(int rssi) {
if (rssi >= -50) return Icons.signal_wifi_4_bar;
if (rssi >= -70) return Icons.signal_wifi_4_bar;
if (rssi >= -90) return Icons.signal_wifi_4_bar;
return Icons.signal_wifi_4_bar;
}
}

374
apps/client/lib/modules/ble/views/ble_view.dart

@ -1,374 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
import '../controllers/ble_controller.dart';
class BleView extends GetView<BleController> {
const BleView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
appBar: AppBar(
title: Text(
'bleScanTitle'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
elevation: 0,
iconTheme: IconThemeData(
color: isDarkMode ? Colors.white : Colors.black,
),
actions: [
// 合规(场景 1/2):用户拒绝过蓝牙/定位权限且仍在 48h 冷却期内时,
// 扫描按钮置灰——既不能再弹权限框,也不该让用户点了毫无反应。
Obx(() {
// 读一下 hasPermissions 让 Obx 建立订阅:isPermissionCoolingDown
// 读的是 GetStorage 不是 Rx,单靠它不会触发重建
controller.hasPermissions.value;
final blocked = controller.isPermissionCoolingDown;
return IconButton(
onPressed: blocked
? null
: (controller.isScanning.value
? controller.stopScan
: controller.startScan),
icon: Icon(
controller.isScanning.value ? Icons.stop : Icons.refresh,
color: blocked
? Colors.grey
: (controller.isScanning.value
? Colors.red
: (isDarkMode ? Colors.white : Colors.black)),
),
);
}),
],
),
body: SafeArea(
child: Column(
children: [
// 状态栏
_buildStatusBar(isDarkMode),
// 连接的设备信息
_buildConnectedDevice(isDarkMode),
// 设备列表
Expanded(
child: _buildDeviceList(isDarkMode),
),
],
),
),
floatingActionButton: Obx(() {
controller.hasPermissions.value; // 同上:建立订阅,权限申请结束后重建
final blocked = controller.isPermissionCoolingDown;
return FloatingActionButton(
onPressed: blocked
? null
: (controller.isScanning.value
? controller.stopScan
: controller.startScan),
backgroundColor: blocked
? Colors.grey
: (controller.isScanning.value ? Colors.red : Colors.blue),
child: Icon(
controller.isScanning.value
? Icons.stop
: Icons.bluetooth_searching,
color: Colors.white,
),
);
}),
);
}
// 状态栏
Widget _buildStatusBar(bool isDarkMode) {
return Obx(() => Container(
width: double.infinity,
padding: EdgeInsets.all(16.w),
margin: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[100],
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
children: [
Row(
children: [
Icon(
controller.isBluetoothOn.value
? Icons.bluetooth
: Icons.bluetooth_disabled,
color: controller.isBluetoothOn.value
? Colors.blue
: Colors.red,
size: 24.w,
),
SizedBox(width: 8.w),
Text(
controller.isBluetoothOn.value ? 'bluetoothOn'.tr : '蓝牙已关闭',
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
Spacer(),
if (controller.isScanning.value)
SizedBox(
width: 20.w,
height: 20.w,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
],
),
if (controller.errorMessage.value.isNotEmpty) ...[
SizedBox(height: 8.h),
Text(
controller.errorMessage.value,
style: TextStyle(
color: Colors.red,
fontSize: 14.sp,
),
),
],
if (controller.isScanning.value) ...[
SizedBox(height: 8.h),
Text(
'bleScanning'.tr,
style: TextStyle(
color: Colors.blue,
fontSize: 14.sp,
),
),
],
],
),
));
}
// 已连接设备信息
Widget _buildConnectedDevice(bool isDarkMode) {
return Obx(() {
if (controller.connectedDevice.value == null) {
return SizedBox.shrink();
}
final device = controller.connectedDevice.value!;
return Container(
width: double.infinity,
padding: EdgeInsets.all(16.w),
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
border: Border.all(color: Colors.green, width: 1),
borderRadius: BorderRadius.circular(12.r),
),
child: Row(
children: [
Icon(
Icons.bluetooth_connected,
color: Colors.green,
size: 24.w,
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'bleConnectedDevices'.tr,
style: TextStyle(
color: Colors.green,
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
Text(
device.platformName.isNotEmpty
? device.platformName
: 'bleUnknownDevice'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
fontSize: 16.sp,
fontWeight: FontWeight.bold,
),
),
Text(
device.remoteId.toString(),
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
fontSize: 12.sp,
),
),
],
),
),
IconButton(
onPressed: controller.disconnectDevice,
icon: Icon(
Icons.close,
color: Colors.red,
size: 20.w,
),
),
],
),
);
});
}
// 设备列表
Widget _buildDeviceList(bool isDarkMode) {
return Obx(() {
if (controller.scanResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bluetooth_searching,
size: 64.w,
color: isDarkMode ? Colors.white54 : Colors.black54,
),
SizedBox(height: 16.h),
Text(
controller.isScanning.value ? '正在搜索设备...' : 'bleTapToScan'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white54 : Colors.black54,
fontSize: 16.sp,
),
textAlign: TextAlign.center,
),
],
),
);
}
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16.w),
itemCount: controller.scanResults.length,
itemBuilder: (context, index) {
final result = controller.scanResults[index];
return _buildDeviceItem(result, isDarkMode);
},
);
});
}
// 设备项
Widget _buildDeviceItem(fbp.ScanResult result, bool isDarkMode) {
final deviceName = controller.getDeviceName(result);
final isConnected =
controller.connectedDevice.value?.remoteId == result.device.remoteId;
return Container(
margin: EdgeInsets.only(bottom: 12.h),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: isConnected
? Colors.green
: (isDarkMode ? Colors.grey[700]! : Colors.grey[300]!),
width: isConnected ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: ListTile(
contentPadding: EdgeInsets.all(16.w),
leading: Container(
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
color: isConnected
? Colors.green.withOpacity(0.1)
: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(24.r),
),
child: Icon(
isConnected ? Icons.bluetooth_connected : Icons.bluetooth,
color: isConnected ? Colors.green : Colors.blue,
size: 24.w,
),
),
title: Text(
deviceName,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
fontSize: 16.sp,
fontWeight: FontWeight.bold,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4.h),
Text(
result.device.remoteId.toString(),
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
fontSize: 12.sp,
),
),
SizedBox(height: 4.h),
Row(
children: [
Icon(
controller.getSignalStrengthIcon(result.rssi),
size: 16.w,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
SizedBox(width: 4.w),
Text(
'${result.rssi} dBm (${controller.getSignalStrengthText(result.rssi)})',
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
fontSize: 12.sp,
),
),
],
),
],
),
trailing: isConnected
? Icon(
Icons.check_circle,
color: Colors.green,
size: 24.w,
)
: IconButton(
onPressed: () => controller.connectToDevice(result.device),
icon: Icon(
Icons.connect_without_contact,
color: Colors.blue,
size: 24.w,
),
),
onTap: isConnected
? null
: () => controller.connectToDevice(result.device),
),
);
}
}

11
apps/client/lib/modules/devices/bindings/devices_binding.dart

@ -1,11 +0,0 @@
import '/modules/devices/controllers/devices_controller.dart';
import 'package:get/get.dart';
class DevicesBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<DevicesController>(
() => DevicesController(),
);
}
}

360
apps/client/lib/modules/devices/controllers/devices_controller.dart

@ -1,360 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:device_jieli/device_jieli.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:permission_handler/permission_handler.dart';
import '/core/utils/logger.dart';
import '/core/utils/permission_util.dart';
import '/data/models/device_category.dart';
import '/data/services/agent_module_defaults.dart';
import '/data/models/user_Info.dart';
import '/data/services/ble_manager.dart';
import '/data/services/network/api.dart';
import '/data/utils/jieli_adv_parser.dart';
import '/routes/app_routes.dart';
/// 设备页控制器:扫描杰理设备、绑定(首次)、连接、断开。
class DevicesController extends GetxController {
static const String _tag = 'DevicesController';
late final BleManager _bleManager;
RxList<JieliDevice> get scannedDevices => _bleManager.scannedDevices;
RxBool get isScanning => _bleManager.isScanning;
RxInt get connectionStateRx => _bleManager.connectionStateRx;
String? get currentDeviceAddress => _bleManager.currentDeviceAddress;
/// 正在连接的目标地址(用于 tile UI 反馈)
final RxnString connectingAddress = RxnString();
/// 正在调用绑定接口的目标地址(用于 tile UI 反馈)
final RxnString bindingAddress = RxnString();
/// 当 [User.instance.devices] 变化时触发 UI 重建。
/// 直接读 user 对象不响应式,所以加一个 tick。
final RxInt boundDevicesTick = 0.obs;
@override
void onInit() {
super.onInit();
_bleManager = Get.find<BleManager>();
Future.microtask(startScan);
}
@override
void onClose() {
_bleManager.stopScan();
super.onClose();
}
Future<void> startScan() async {
Logger.i(_tag, 'startScan 被调用');
final granted = await _ensureBluetoothPermission();
if (!granted) {
Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr);
return;
}
// 从「选择设备」页带过来的品类:只扫该品类产品的 scanuuid;
// 没带品类(或品类圈不出产品)时退回扫描全部 UUID
final String? categoryId = Get.arguments is Map
? (Get.arguments as Map)['categoryId'] as String?
: null;
_categoryId = categoryId;
final uuids = DeviceCategories.scanUuidsFor(categoryId);
Logger.i(_tag,
'扫描品类=${categoryId ?? "全部"}, uuid 数=${uuids.length}');
final ok = await _bleManager.startScan(uuids: uuids);
if (!ok) {
final err = _bleManager.lastScanError ?? 'unknown';
Logger.w(_tag, '扫描启动失败: $err');
Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err');
}
}
Future<void> stopScan() async {
await _bleManager.stopScan();
}
/// 从「选择设备」页带过来的品类 id,连接成功后按它铺 AI 页默认功能
String? _categoryId;
/// 当前用户已绑定的设备里是否包含这个 BLE 设备(按 MAC 匹配)。
bool isDeviceBound(JieliDevice device) {
if (!User.isLoggedIn()) return false;
return _findBoundDevice(device) != null;
}
UserDevice? _findBoundDevice(JieliDevice device) {
final addr = device.address.replaceAll(':', '').toUpperCase();
final edr = (device.edrAddr ?? '').replaceAll(':', '').toUpperCase();
for (final d in User.instance.devices) {
final dm = d.devicemac.replaceAll(':', '').toUpperCase();
if (dm.isEmpty) continue;
if (dm == addr || (edr.isNotEmpty && dm == edr)) return d;
}
return null;
}
/// 点击"连接"或"绑定后自动连接"时调用。
Future<void> connect(JieliDevice device) async {
if (connectingAddress.value != null) {
Logger.i(_tag, '连接进行中,忽略重复点击: ${device.address}');
return;
}
if (_bleManager.isConnected &&
_bleManager.currentDeviceAddress == device.address) {
Logger.i(_tag, '设备已处于连接状态,直接进入首页: ${device.address}');
Get.offAllNamed(Routes.home);
return;
}
Logger.i(_tag, '连接设备: ${device.name} ${device.address}');
connectingAddress.value = device.address;
await _bleManager.stopScan();
final completer = Completer<bool>();
bool gotConnectionOk = false;
final evtSub = Jielihome.instance.events.listen((evt) {
if (evt is ConnectionStateEvent) {
if (evt.address != device.address) return;
if (evt.state == ConnectionStateEvent.connectionOk) {
gotConnectionOk = true;
} else if (evt.state == ConnectionStateEvent.connectionDisconnect) {
if (!completer.isCompleted) completer.complete(false);
}
} else if (evt is RcspInitEvent) {
if (evt.address != device.address) return;
if (evt.success && gotConnectionOk) {
if (!completer.isCompleted) completer.complete(true);
} else if (!evt.success) {
if (!completer.isCompleted) completer.complete(false);
}
}
});
_showConnectingDialog(device);
try {
await _bleManager.connectDevice(device);
final ok = await completer.future
.timeout(const Duration(seconds: 20), onTimeout: () => false);
if (Get.isDialogOpen ?? false) Get.back();
if (ok) {
// 首次连上该品类设备 → AI 页铺开这个品类的默认功能编排
AgentDefaults.onJieliConnected(_categoryId);
Get.offAllNamed(Routes.home);
} else {
Get.snackbar('error'.tr, 'connectionFailure'.tr);
}
} catch (e) {
Logger.e(_tag, '连接异常: $e');
if (Get.isDialogOpen ?? false) Get.back();
Get.snackbar('error'.tr, 'connectionFailure'.tr);
} finally {
await evtSub.cancel();
connectingAddress.value = null;
}
}
/// 点击"绑定"按钮:解析广播 → 调用 binddevice → 成功后自动 connect。
Future<void> bindAndConnect(JieliDevice device) async {
if (bindingAddress.value != null || connectingAddress.value != null) {
Logger.i(_tag, '已有绑定/连接进行中,忽略重复点击: ${device.address}');
return;
}
if (!User.isLoggedIn()) {
Get.snackbar('tip'.tr, 'pleaseLoginFirst'.tr);
return;
}
final info = JieliAdvParser.parse(device);
if (info == null || info.code.isEmpty) {
Logger.w(_tag, '无法从广播包解析绑定信息: ${device.address}');
Get.snackbar('error'.tr, 'deviceUnauthorized'.tr);
return;
}
bindingAddress.value = device.address;
try {
final response = await Api.binddevice({
'pid': info.pid,
'code': info.code,
'devicename': device.name,
'devicemac': device.address,
});
Logger.i(_tag, '绑定接口返回: $response');
if (response == null) {
Get.snackbar('error'.tr, 'deviceUnauthorized'.tr);
Get.offAllNamed(Routes.devices);
return;
}
final deviceJson = response['device'];
if (deviceJson == null || deviceJson == '') {
Get.snackbar('error'.tr, 'deviceUnauthorized'.tr);
Get.offAllNamed(Routes.devices);
return;
}
final newUserDevice =
UserDevice.fromJson(deviceJson as Map<String, dynamic>);
User.instance.addUserDevice(newUserDevice);
boundDevicesTick.value++;
// 首次绑定奖励
if (response['isgiveaway'] == true) {
_applyGiveaway(response);
}
// 绑定成功后自动连接进入主界面
await connect(device);
} catch (e) {
Logger.e(_tag, '绑定异常: $e');
Get.snackbar('error'.tr, 'deviceUnauthorized'.tr);
} finally {
bindingAddress.value = null;
}
}
void _applyGiveaway(Map<String, dynamic> response) {
User.instance.viplv = (response['viplv'] as num?)?.toInt() ?? User.instance.viplv;
User.instance.vipexptime =
(response['vipexptime'] as num?)?.toInt() ?? User.instance.vipexptime;
User.instance.aichatintegral =
(response['aichatintegral'] as num?)?.toInt() ?? User.instance.aichatintegral;
User.instance.aichattotalintegral =
(response['aichattotalintegral'] as num?)?.toInt() ??
User.instance.aichattotalintegral;
User.instance.tradeintegral =
(response['tradeintegral'] as num?)?.toInt() ?? User.instance.tradeintegral;
User.instance.tradetotalintegral =
(response['tradetotalintegral'] as num?)?.toInt() ??
User.instance.tradetotalintegral;
User.instance.meetintegral =
(response['meetintegral'] as num?)?.toInt() ?? User.instance.meetintegral;
User.instance.meettotalintegral =
(response['meettotalintegral'] as num?)?.toInt() ??
User.instance.meettotalintegral;
Get.dialog(
AlertDialog(
title: Text('bindRewardTitle'.tr),
content: Text('bindRewardSimpleDesc'.tr),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text('ok'.tr),
),
],
),
barrierDismissible: true,
);
}
void _showConnectingDialog(JieliDevice device) {
Get.dialog(
PopScope(
canPop: false,
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: EdgeInsets.symmetric(horizontal: 40.w),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 28.h),
decoration: BoxDecoration(
color: Get.isDarkMode
? const Color(0xFF1C1C1E)
: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 44.w,
height: 44.w,
child: const CircularProgressIndicator(
strokeWidth: 3,
valueColor:
AlwaysStoppedAnimation<Color>(Color(0xFF0066CC)),
),
),
SizedBox(height: 18.h),
Text(
'connectingDevice'.tr,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Get.isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(height: 6.h),
Text(
device.name.isEmpty ? device.address : device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
color: Get.isDarkMode ? Colors.white54 : Colors.black54,
),
),
],
),
),
),
),
barrierDismissible: false,
);
}
Future<void> disconnect() async {
await _bleManager.disconnect();
}
bool isConnected(JieliDevice device) {
return _bleManager.isConnected &&
_bleManager.currentDeviceAddress == device.address;
}
/// 扫描设备需要的权限(Android)
static const List<Permission> _scanPermissions = [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse,
];
/// 权限被拒且仍在 48h 冷却期 —— 视图据此把"扫描设备"入口置灰
bool get isBluetoothPermissionCoolingDown {
if (Platform.isIOS) return false;
return _scanPermissions
.any((p) => PermissionUtil.instance.isDenialCoolingDown(p));
}
Future<bool> _ensureBluetoothPermission() async {
// iOS: CoreBluetooth 首次扫描时由系统自动弹出蓝牙权限对话框
// (依赖 Info.plist 的 NSBluetoothAlwaysUsageDescription),
// permission_handler 的 bluetoothScan/bluetoothConnect 是 Android 专属,
// 在 iOS 上调用永远返回 denied,会误拦截扫描。
if (Platform.isIOS) return true;
try {
// 合规(场景 1/2):走统一网关,用户拒绝一次后 48 小时内不再申请,
// 返回 false 时调用方置灰扫描入口,不得再弹窗或跳设置页。
return await PermissionUtil.instance.requestPermissions(
permissions: _scanPermissions,
permissionName: 'bluetooth'.tr,
explanationText: 'bluetoothScanPermissionDesc'.tr,
);
} catch (e) {
Logger.error('请求蓝牙权限失败: $e');
return false;
}
}
}

512
apps/client/lib/modules/devices/views/devices_view.dart

@ -1,512 +0,0 @@
import 'package:device_jieli/device_jieli.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/devices_controller.dart';
import '../../../core/theme/app_palette.dart';
class DevicesView extends StatefulWidget {
const DevicesView({Key? key}) : super(key: key);
@override
State<DevicesView> createState() => _DevicesViewState();
}
class _DevicesViewState extends State<DevicesView>
with TickerProviderStateMixin {
late AnimationController _pulseController;
late AnimationController _rotationController;
late Animation<double> _pulseAnimation;
late Animation<double> _rotationAnimation;
DevicesController get controller => Get.find<DevicesController>();
@override
void initState() {
super.initState();
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat(reverse: true);
_rotationController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
)..repeat();
_pulseAnimation =
Tween<double>(begin: 0.8, end: 1.2).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_rotationAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _rotationController, curve: Curves.linear),
);
}
@override
void dispose() {
_pulseController.dispose();
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? Colors.black : Colors.white,
body: SafeArea(
child: Stack(
children: [
Column(
children: [
_buildTopSection(isDark),
Expanded(child: _buildDeviceListSection(isDark)),
_buildBottomActions(isDark),
],
),
// 返回箭头(左上角,悬浮不挤压居中标题)
Positioned(
left: 12.w,
top: 8.h,
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
width: 40.w,
height: 40.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.black.withValues(alpha: 0.04),
),
child: Icon(
Icons.arrow_back_ios_new,
size: 18.sp,
color: isDark ? Colors.white : AppPalette.brand900,
),
),
),
),
],
),
),
);
}
// ─── 顶部:标题 + 扫描动画 + 状态文字 ────────────────────
Widget _buildTopSection(bool isDark) {
return Padding(
padding: EdgeInsets.only(top: 40.h, left: 24.w, right: 24.w),
child: Column(
children: [
Text(
'Eaimar',
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
),
),
SizedBox(height: 6.h),
Text(
'homeSlogan'.tr,
style: TextStyle(
fontSize: 14.sp,
color: isDark ? Colors.white60 : Colors.black54,
),
),
SizedBox(height: 24.h),
_buildScanAnimation(isDark),
SizedBox(height: 16.h),
Obx(() {
final scanning = controller.isScanning.value;
return Text(
scanning ? 'scanning'.tr : 'scanIdle'.tr,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white70 : Colors.black87,
),
);
}),
SizedBox(height: 6.h),
Text(
'ensureHeadsetOnAndPairingMode'.tr,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color: isDark ? Colors.white38 : Colors.black45,
),
),
],
),
);
}
Widget _buildScanAnimation(bool isDark) {
const primaryColor = Color(0xFF0066CC);
const secondaryColor = Color(0xFF5E5CE6);
return SizedBox(
width: 180.w,
height: 180.w,
child: Obx(() {
final scanning = controller.isScanning.value;
return Stack(
alignment: Alignment.center,
children: [
if (scanning) _buildPulse(primaryColor, secondaryColor),
if (scanning) _buildRotatingBorder(primaryColor),
Container(
width: 92.w,
height: 92.w,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primaryColor, secondaryColor],
),
shape: BoxShape.circle,
),
child: Icon(
Icons.headphones,
color: Colors.white,
size: 44.w,
),
),
],
);
}),
);
}
Widget _buildPulse(Color primary, Color secondary) {
return AnimatedBuilder(
animation: _pulseAnimation,
builder: (_, __) {
final v = _pulseAnimation.value;
final opacity = 0.4 - (_pulseController.value * 0.3);
return Container(
width: 160.w * v,
height: 160.w * v,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
primary.withValues(alpha: opacity),
secondary.withValues(alpha: opacity),
],
),
shape: BoxShape.circle,
),
);
},
);
}
Widget _buildRotatingBorder(Color primary) {
return AnimatedBuilder(
animation: _rotationAnimation,
builder: (_, __) {
return Transform.rotate(
angle: _rotationAnimation.value * 2 * 3.14159,
child: SizedBox(
width: 110.w,
height: 110.w,
child: CircularProgressIndicator(
strokeWidth: 3.w,
valueColor: AlwaysStoppedAnimation<Color>(primary),
backgroundColor: Colors.grey.withValues(alpha: 0.18),
value: 0.7,
),
),
);
},
);
}
// ─── 底部:扫描结果列表 ────────────────────────────────
Widget _buildDeviceListSection(bool isDark) {
return Container(
margin: EdgeInsets.only(top: 20.h, left: 16.w, right: 16.w),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.03)
: const Color(0xFFF5F6FA),
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
EdgeInsets.only(left: 16.w, right: 16.w, top: 14.h, bottom: 8.h),
child: Row(
children: [
Text(
'discoveredDevices'.tr,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black87,
),
),
const Spacer(),
Obx(() => Text(
'${controller.scannedDevices.length}',
style: TextStyle(
fontSize: 12.sp,
color: isDark ? Colors.white54 : Colors.black54,
),
)),
],
),
),
Expanded(
child: Obx(() {
final devices = controller.scannedDevices;
if (devices.isEmpty) {
return Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 32.h),
child: Text(
'noDevicesFound'.tr,
style: TextStyle(
fontSize: 13.sp,
color: isDark ? Colors.white38 : Colors.black45,
),
),
),
);
}
return ListView.separated(
padding: EdgeInsets.fromLTRB(8.w, 4.h, 8.w, 12.h),
itemCount: devices.length,
separatorBuilder: (_, __) => SizedBox(height: 6.h),
itemBuilder: (_, i) => _buildDeviceTile(devices[i], isDark),
);
}),
),
],
),
);
}
Widget _buildDeviceTile(JieliDevice device, bool isDark) {
return Obx(() {
// 订阅连接状态、当前连接地址、正在连接/绑定的目标 + 已绑定列表 tick,让 tile 实时刷新
controller.connectionStateRx.value;
controller.boundDevicesTick.value;
final connected = controller.isConnected(device);
final connectingAddr = controller.connectingAddress.value;
final bindingAddr = controller.bindingAddress.value;
final connecting = connectingAddr == device.address;
final binding = bindingAddr == device.address;
final disabled = (connectingAddr != null && !connecting) ||
(bindingAddr != null && !binding);
final bound = controller.isDeviceBound(device);
return _buildDeviceTileContent(
device,
isDark,
connected: connected,
connecting: connecting,
binding: binding,
disabled: disabled,
bound: bound,
);
});
}
Widget _buildDeviceTileContent(
JieliDevice device,
bool isDark, {
required bool connected,
required bool connecting,
required bool binding,
required bool disabled,
required bool bound,
}) {
const connectedColor = Color(0xFF22C55E);
final canTap = !connecting && !binding && !disabled;
void onPrimaryTap() {
if (bound) {
controller.connect(device);
} else {
controller.bindAndConnect(device);
}
}
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12.r),
onTap: canTap ? onPrimaryTap : null,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
color: isDark
? Colors.white.withValues(alpha: 0.04)
: Colors.white,
border: Border.all(
color: connected
? connectedColor.withValues(alpha: 0.3)
: (isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.05)),
),
),
child: Row(
children: [
Container(
width: 36.w,
height: 36.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: connected
? connectedColor.withValues(alpha: 0.15)
: const Color(0xFF0066CC).withValues(alpha: 0.12),
),
child: Icon(
Icons.headphones,
size: 18.sp,
color: connected
? connectedColor
: const Color(0xFF0066CC),
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
device.name.isEmpty
? 'unnamedDevice'.tr
: device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
),
if (connected)
Container(
padding: EdgeInsets.symmetric(
horizontal: 6.w, vertical: 2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
color: connectedColor.withValues(alpha: 0.12),
),
child: Text(
'connected'.tr,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w600,
color: connectedColor,
),
),
),
],
),
SizedBox(height: 2.h),
Text(
'${device.address}${device.rssi != null ? ' RSSI ${device.rssi}' : ''}',
style: TextStyle(
fontSize: 10.sp,
color: isDark ? Colors.white38 : Colors.black45,
fontFamily: 'Menlo',
),
),
],
),
),
if (connected)
TextButton(
onPressed: controller.disconnect,
style: TextButton.styleFrom(
minimumSize: Size(0, 28.h),
padding: EdgeInsets.symmetric(horizontal: 8.w),
),
child: Text(
'disconnect'.tr,
style: TextStyle(fontSize: 11.sp, color: Colors.red),
),
)
else if (connecting || binding)
SizedBox(
width: 18.w,
height: 18.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Color(0xFF0066CC)),
),
)
else
ElevatedButton(
onPressed: disabled ? null : onPrimaryTap,
style: ElevatedButton.styleFrom(
backgroundColor: bound
? const Color(0xFF0066CC)
: const Color(0xFF8B5CF6),
foregroundColor: Colors.white,
minimumSize: Size(0, 30.h),
padding: EdgeInsets.symmetric(horizontal: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
elevation: 0,
),
child: Text(
bound ? 'connect'.tr : 'bind'.tr,
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
// ─── 底部:扫描操作按钮 ────────────────────────────────
Widget _buildBottomActions(bool isDark) {
return Padding(
padding: EdgeInsets.fromLTRB(24.w, 12.h, 24.w, 20.h),
child: Obx(() {
final scanning = controller.isScanning.value;
return SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton.icon(
onPressed: scanning ? controller.stopScan : controller.startScan,
icon: Icon(scanning ? Icons.stop : Icons.refresh, size: 18.sp),
label: Text(
scanning ? 'stopScan'.tr : 'startScan'.tr,
style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0066CC),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.r),
),
elevation: 0,
),
),
);
}),
);
}
}

10
apps/client/lib/modules/eq_settings/bindings/eq_settings_binding.dart

@ -1,10 +0,0 @@
import 'package:get/get.dart';
import '../controllers/eq_settings_controller.dart';
class EqSettingsBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<EqSettingsController>(() => EqSettingsController());
}
}

217
apps/client/lib/modules/eq_settings/controllers/eq_settings_controller.dart

@ -1,217 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart';
import '../../../routes/app_routes.dart';
import '../views/widgets/row_confirm_widget.dart';
import '../views/widgets/row_equalizer_widget.dart';
import '../views/widgets/row_grade_widget.dart';
import '../views/widgets/row_input_widget.dart';
import '../views/widgets/row_keypress_widget.dart';
import '../views/widgets/row_radio_widget.dart';
import '../views/widgets/row_switch_widget.dart';
import '../views/widgets/row_timer_widget.dart';
class EqSettingsController extends GetxController {
final RxList uiList = [
{
'title': '音效',
'templates': [
{
'type': 'equalizer',
'name': '均衡器',
'value': [],
'options': [
{
'label': '默认',
'value': [1, 2, 3, 4, 5, 4, 3, 2, 1, 0],
'default': true,
},
{
'label': '流行',
'value': [1, 0, 3, 4, 5, 4, 1, 2, 1, 0],
'default': true,
},
{
'label': '摇滚',
'value': [2, 3, 4, 5, 4, 3, 2, 1, 0, -1],
'default': true,
},
{
'label': '爵士',
'value': [3, 4, 0, 4, 3, 2, 1, 0, -1, -2],
'default': true,
},
{
'label': '经典',
'value': [3, 4, 2, 4, 3, 2, 1, 0, -1, -2],
'default': true,
},
{
'label': '乡村',
'value': [3, 4, 1, 4, 3, 2, 1, 0, -1, -2],
'default': true,
},
],
'xAxis': [
'8',
'31',
'62',
'125',
'250',
'500',
'1K',
'2K',
'4K',
'16K'
],
'onCallback': (List value) {
Logger.d('equalizer', '均衡器:$value');
},
}
],
},
{
'title': '按键功能',
'templates': [
{
'type': 'keypress',
'data': [
{'key': 'key1', 'label': '单击', 'left': 1, 'right': 1},
{'key': 'key2', 'label': '双击', 'left': 1, 'right': 1},
{'key': 'key3', 'label': '三击', 'left': 1, 'right': 1},
{'key': 'key4', 'label': '长按', 'left': 1, 'right': 1},
],
'options': [
{'label': '无作用', 'value': 0},
{'label': '回拨电话', 'value': 1},
{'label': '语音助手', 'value': 2},
{'label': '上一曲', 'value': 3},
{'label': '下一曲', 'value': 4},
{'label': '音量加', 'value': 5},
{'label': '音量减', 'value': 6},
{'label': '播放暂停', 'value': 7},
{'label': '游戏模式', 'value': 8},
{'label': '降噪设置', 'value': 9},
{'label': 'EQ切换', 'value': 10},
],
'onCallback': (Map value) {
Logger.d('keypress', '按键功能:$value');
},
},
],
},
{
'title': '耳机功能',
'templates': [
{
'type': 'input',
'name': '修改名称',
'value': 'eaimar',
'onCallback': (String value) {
Logger.d('input', '修改名称:$value');
},
},
{
'type': 'radio',
'name': '工作模式',
'value': 1,
'options': [
{'label': '游戏模式', 'value': 1},
{'label': '娱乐模式', 'value': 2},
{'label': '睡眠模式', 'value': 3},
],
'onCallback': (int value) {
Logger.d('radio', '工作模式:$value');
},
},
{
'type': 'switch',
'name': '闪灯开关',
'value': false,
'onCallback': (bool value) {
Logger.d('switch', '闪灯开关:$value');
},
},
{
'type': 'switch',
'name': '查找设备',
'value': false,
'description': '在设备上播放声音',
'onCallback': (bool value) {
Logger.d('switch', '查找设备:$value');
},
},
{
'type': 'grade',
'name': '降噪等级',
'value': 0,
'onCallback': (int value) {
Logger.d('grade', '降噪等级:$value');
},
},
{
'type': 'timer',
'name': '定时关机',
'value': 0,
'onCallback': (int value) {
Logger.d('timer', '定时关机:$value');
},
},
],
},
{
'templates': [
{
'type': 'confirm',
'name': '升级耳机固件',
'onCallback': () => Get.toNamed(Routes.OTA_UPGRADE),
},
{
'type': 'confirm',
'name': '恢复出厂设置',
'tips': '确定要恢复出厂设置吗?',
'onCallback': () {
Logger.d('confirm', '恢复出厂设置');
},
},
{
'type': 'confirm',
'name': '清除配对记录',
'tips': '确定要清除配对记录吗?',
'onCallback': () {
Logger.d('confirm', '清除配对记录');
},
},
],
}
].obs;
final GetStorage _storage = GetStorage();
@override
void onInit() {
super.onInit();
final List equalizerOptions = _storage.read('equalizer_options') ?? [];
if (equalizerOptions.isNotEmpty) {
List options =
equalizerOptions.map((e) => Map<String, Object>.from(e)).toList();
uiList[0]['templates'][0]['options'].addAll(options);
}
}
Widget getTemplate(Map data) {
return switch (data['type']) {
'equalizer' => RowEqualizerWidget(data),
'keypress' => RowKeypressWidget(data),
'input' => RowInputWidget(data),
'radio' => RowRadioWidget(data),
'switch' => RowSwitchWidget(data),
'grade' => RowGradeWidget(data),
'timer' => RowTimerWidget(data),
'confirm' => RowConfirmWidget(data),
_ => const SizedBox.shrink(),
};
}
}

98
apps/client/lib/modules/eq_settings/views/eq_settings_view.dart

@ -1,98 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/eq_settings_controller.dart';
class EqSettingsView extends GetView<EqSettingsController> {
const EqSettingsView({super.key});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
appBar: _buildAppBar(isDarkMode),
body: SafeArea(
top: false,
child: _buildBody(isDarkMode),
),
);
}
// 构建应用栏
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
return AppBar(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0.5,
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
onPressed: () => Get.back(),
),
title: Text(
'设备设置',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
centerTitle: true,
);
}
// 构建主体内容
Widget _buildBody(bool isDarkMode) {
return Obx(
() => ListView.builder(
itemCount: controller.uiList.length,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w),
itemBuilder: (BuildContext context, int index) {
Map data = controller.uiList[index];
return _item(isDarkMode, data);
},
),
);
}
Widget _item(bool isDarkMode, Map data) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w),
margin: EdgeInsets.symmetric(vertical: 5.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.white.withValues(alpha: 0.05) : Colors.white,
borderRadius: BorderRadius.circular(8.w),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (data['title'] != null)
Text(
data['title'],
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
...data['templates'].map((e) => controller.getTemplate(e)).toList(),
],
),
);
}
}

96
apps/client/lib/modules/eq_settings/views/widgets/row_confirm_widget.dart

@ -1,96 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
class RowConfirmWidget extends StatefulWidget {
final Map templateData;
const RowConfirmWidget(this.templateData, {super.key});
@override
State<RowConfirmWidget> createState() => _RowConfirmWidgetState();
}
class _RowConfirmWidgetState extends State<RowConfirmWidget> {
Map get _templateData => widget.templateData;
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () {
if (_templateData['tips'] != null) {
_alertDialog(isDarkMode);
} else {
_templateData['onCallback']();
}
},
behavior: HitTestBehavior.translucent,
child: Padding(
padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w),
child: Row(
children: [
Expanded(
child: Text(
_templateData['name'],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Icon(
Icons.chevron_right,
size: 26.w,
color: Colors.blue,
),
],
),
),
);
}
void _alertDialog(bool isDarkMode) {
Get.dialog(
AlertDialog(
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
title: Text(
_templateData['name'],
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
),
),
content: Text(
_templateData['tips'],
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text(
'cancel'.tr, // 取消
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
),
TextButton(
onPressed: () {
_templateData['onCallback']();
Get.back();
},
child: Text(
'confirm'.tr, // 确定
style: TextStyle(
color: isDarkMode ? Colors.red[300] : Colors.red[600],
),
),
),
],
),
);
}
}

83
apps/client/lib/modules/eq_settings/views/widgets/row_equalizer_widget.dart

@ -1,83 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'select_equalizer_widget.dart';
class RowEqualizerWidget extends StatefulWidget {
final Map templateData;
const RowEqualizerWidget(this.templateData, {super.key});
@override
State<RowEqualizerWidget> createState() => _RowEqualizerWidgetState();
}
class _RowEqualizerWidgetState extends State<RowEqualizerWidget> {
Map get _templateData => widget.templateData;
String _label = '';
@override
void initState() {
super.initState();
_label = _templateData['options'].firstWhere(
(option) => listEquals(_templateData['value'], option['value']),
orElse: () => {'label': ''},
)['label'];
}
void _setLabel() {
setState(() {
_label = _templateData['options'].firstWhere(
(option) => listEquals(_templateData['value'], option['value']),
orElse: () => {'label': ''},
)['label'];
});
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () async {
var result = await Get.to(() => SelectEqualizerWidget(_templateData));
_setLabel();
if (result != null) {
_templateData['onCallback'](_templateData['value']);
}
},
behavior: HitTestBehavior.translucent,
child: Padding(
padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w),
child: Row(
children: [
Expanded(
child: Text(
_templateData['name'],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Text(
_label,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
Icon(
Icons.chevron_right,
size: 26.w,
color: Colors.blue,
),
],
),
),
);
}
}

63
apps/client/lib/modules/eq_settings/views/widgets/row_grade_widget.dart

@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class RowGradeWidget extends StatefulWidget {
final Map templateData;
const RowGradeWidget(this.templateData, {super.key});
@override
State<RowGradeWidget> createState() => _RowGradeWidgetState();
}
class _RowGradeWidgetState extends State<RowGradeWidget> {
Map get _templateData => widget.templateData;
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w),
child: Row(
children: [
Expanded(
child: Text(
_templateData['name'],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Slider(
value: _templateData['value'].toDouble(),
min: 0,
max: 10,
divisions: 10,
activeColor: Colors.blue,
onChanged: (value) {
setState(() {
_templateData['value'] = value.toInt();
});
},
onChangeEnd: (value) {
_templateData['onCallback'](_templateData['value']);
},
),
SizedBox(
width: 15.w,
child: Text(
_templateData['value'].toString(),
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
),
],
),
);
}
}

76
apps/client/lib/modules/eq_settings/views/widgets/row_input_widget.dart

@ -1,76 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'settings_input_widget.dart';
class RowInputWidget extends StatefulWidget {
final Map templateData;
const RowInputWidget(this.templateData, {super.key});
@override
State<RowInputWidget> createState() => _RowInputWidgetState();
}
class _RowInputWidgetState extends State<RowInputWidget> {
Map get _templateData => widget.templateData;
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () => _inputBottomSheet(isDarkMode),
behavior: HitTestBehavior.translucent,
child: Padding(
padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w),
child: Row(
children: [
Expanded(
child: Text(
_templateData['name'],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Text(
_templateData['value'],
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
Icon(
Icons.chevron_right,
size: 26.w,
color: Colors.blue,
),
],
),
),
);
}
void _inputBottomSheet(bool isDarkMode) {
Get.bottomSheet(
SettingsInputWidget(_templateData['name'], _templateData['value']),
isScrollControlled: true,
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
).then((value) {
if (value != null) {
setState(() {
_templateData['value'] = value;
});
_templateData['onCallback'](value);
}
});
}
}

242
apps/client/lib/modules/eq_settings/views/widgets/row_keypress_widget.dart

@ -1,242 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
class RowKeypressWidget extends StatefulWidget {
final Map templateData;
const RowKeypressWidget(this.templateData, {super.key});
@override
State<RowKeypressWidget> createState() => _RowKeypressWidgetState();
}
class _RowKeypressWidgetState extends State<RowKeypressWidget> {
Map get _templateData => widget.templateData;
String _getLabel(int value) {
return _templateData['options'].firstWhere((item) => item['value'] == value,
orElse: () => {'label': ''})['label'];
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 10.w),
child: Row(
children: [
const Spacer(),
Expanded(
child: Text(
'左',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Expanded(
child: Text(
'右',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
],
),
),
..._templateData['data']
.map((item) => _rowItem(isDarkMode, item))
.toList(),
],
);
}
Widget _rowItem(bool isDarkMode, Map item) {
return Row(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.w),
child: Text(
item['label'],
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => _radioDialog(isDarkMode, item, 'left'),
behavior: HitTestBehavior.translucent,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.w),
child: Text(
_getLabel(item['left']),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => _radioDialog(isDarkMode, item, 'right'),
behavior: HitTestBehavior.translucent,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.w),
child: Text(
_getLabel(item['right']),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
),
),
),
],
);
}
void _radioDialog(bool isDarkMode, Map item, String key) {
Get.dialog(
Dialog(
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
child: Padding(
padding: EdgeInsets.all(12.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${item['label']}-${key == 'left' ? '左' : '右'}',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(height: 16.h),
Container(
constraints: BoxConstraints(maxHeight: 0.5.sh),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _templateData['options']
.map<Widget>((option) =>
_radioItem(isDarkMode, option, item, key))
.toList(),
),
),
),
SizedBox(height: 16.h),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: Text(
'cancel'.tr, // 取消
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
),
],
),
],
),
),
),
);
}
Widget _radioItem(bool isDarkMode, Map option, Map item, String key) {
return GestureDetector(
onTap: () {
setState(() {
item[key] = option['value'];
});
Map data = {
'key': item['key'],
key: option['value'],
};
_templateData['onCallback'](data);
Get.back();
},
child: Container(
margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.all(10.w),
decoration: BoxDecoration(
color: item[key] == option['value']
? (isDarkMode
? Colors.teal[900]!.withValues(alpha: 0.3)
: Colors.teal[50]!)
: (isDarkMode
? Colors.white.withValues(alpha: 0.05)
: Colors.grey[100]!),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: item[key] == option['value']
? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!)
: Colors.transparent,
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Text(
option['label'],
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
Container(
width: 24.w,
height: 24.w,
decoration: BoxDecoration(
color: item[key] == option['value']
? (isDarkMode ? Colors.teal[300] : Colors.teal[600])
: (isDarkMode
? Colors.white.withValues(alpha: 0.1)
: Colors.grey[300]),
shape: BoxShape.circle,
),
child: item[key] == option['value']
? Icon(
Icons.check,
size: 16.w,
color: Colors.white,
)
: null,
),
],
),
),
);
}
}

187
apps/client/lib/modules/eq_settings/views/widgets/row_radio_widget.dart

@ -1,187 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
class RowRadioWidget extends StatefulWidget {
final Map templateData;
const RowRadioWidget(this.templateData, {super.key});
@override
State<RowRadioWidget> createState() => _RowRadioWidgetState();
}
class _RowRadioWidgetState extends State<RowRadioWidget> {
Map get _templateData => widget.templateData;
String _label = '';
@override
void initState() {
super.initState();
_label = _templateData['options'].firstWhere(
(option) => option['value'] == _templateData['value'],
orElse: () => {'label': ''},
)['label'];
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () => _radioDialog(isDarkMode),
behavior: HitTestBehavior.translucent,
child: Padding(
padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w),
child: Row(
children: [
Expanded(
child: Text(
_templateData['name'],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Text(
_label,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
Icon(
Icons.chevron_right,
size: 26.w,
color: Colors.blue,
),
],
),
),
);
}
void _radioDialog(bool isDarkMode) {
Get.dialog(
Dialog(
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
child: Padding(
padding: EdgeInsets.all(12.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_templateData['name'],
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(height: 16.h),
Container(
constraints: BoxConstraints(maxHeight: 0.5.sh),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _templateData['options']
.map<Widget>((option) => _item(isDarkMode, option))
.toList(),
),
),
),
SizedBox(height: 16.h),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: Text(
'cancel'.tr, // 取消
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
),
],
),
],
),
),
),
);
}
Widget _item(bool isDarkMode, Map option) {
return GestureDetector(
onTap: () {
setState(() {
_label = option['label'];
});
_templateData['value'] = option['value'];
_templateData['onCallback'](option['value']);
Get.back();
},
child: Container(
margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.all(10.w),
decoration: BoxDecoration(
color: _templateData['value'] == option['value']
? (isDarkMode
? Colors.teal[900]!.withValues(alpha: 0.3)
: Colors.teal[50]!)
: (isDarkMode
? Colors.white.withValues(alpha: 0.05)
: Colors.grey[100]!),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: _templateData['value'] == option['value']
? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!)
: Colors.transparent,
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Text(
option['label'],
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
Container(
width: 24.w,
height: 24.w,
decoration: BoxDecoration(
color: _templateData['value'] == option['value']
? (isDarkMode ? Colors.teal[300] : Colors.teal[600])
: (isDarkMode
? Colors.white.withValues(alpha: 0.1)
: Colors.grey[300]),
shape: BoxShape.circle,
),
child: _templateData['value'] == option['value']
? Icon(
Icons.check,
size: 16.w,
color: Colors.white,
)
: null,
),
],
),
),
);
}
}

61
apps/client/lib/modules/eq_settings/views/widgets/row_switch_widget.dart

@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class RowSwitchWidget extends StatefulWidget {
final Map templateData;
const RowSwitchWidget(this.templateData, {super.key});
@override
State<RowSwitchWidget> createState() => _RowSwitchWidgetState();
}
class _RowSwitchWidgetState extends State<RowSwitchWidget> {
Map get _templateData => widget.templateData;
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w),
child: Row(
children: [
Expanded(
child: Text(
_templateData['name'],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
if (_templateData['description'] != null)
Text(
_templateData['description'],
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
SizedBox(
height: 26.w,
child: Transform.scale(
scale: 0.8,
alignment: Alignment.centerRight,
child: Switch(
value: _templateData['value'],
onChanged: (bool value) {
setState(() {
_templateData['value'] = value;
});
_templateData['onCallback'](value);
},
),
),
),
],
),
);
}
}

77
apps/client/lib/modules/eq_settings/views/widgets/row_timer_widget.dart

@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'select_timer_widget.dart';
class RowTimerWidget extends StatefulWidget {
final Map templateData;
const RowTimerWidget(this.templateData, {super.key});
@override
State<RowTimerWidget> createState() => _RowTimerWidgetState();
}
class _RowTimerWidgetState extends State<RowTimerWidget> {
Map get _templateData => widget.templateData;
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () => _timerBottomSheet(isDarkMode),
behavior: HitTestBehavior.translucent,
child: Padding(
padding: EdgeInsets.fromLTRB(10.w, 10.w, 0, 10.w),
child: Row(
children: [
Expanded(
child: Text(
_templateData['name'],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
if (_templateData['value'] > 0)
Text(
'${_templateData['value']}分钟',
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
Icon(
Icons.chevron_right,
size: 26.w,
color: Colors.blue,
),
],
),
),
);
}
void _timerBottomSheet(bool isDarkMode) {
Get.bottomSheet(
SelectTimerWidget(_templateData['name'], _templateData['value']),
isScrollControlled: true,
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
).then((value) {
if (value != null) {
setState(() {
_templateData['value'] = value;
});
_templateData['onCallback'](value);
}
});
}
}

308
apps/client/lib/modules/eq_settings/views/widgets/select_equalizer_widget.dart

@ -1,308 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'settings_input_widget.dart';
class SelectEqualizerWidget extends StatefulWidget {
final Map templateData;
const SelectEqualizerWidget(this.templateData, {super.key});
@override
State<SelectEqualizerWidget> createState() => _SelectEqualizerWidgetState();
}
class _SelectEqualizerWidgetState extends State<SelectEqualizerWidget> {
Map get _templateData => widget.templateData;
List _values = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
bool isEdit = false;
final GetStorage _storage = GetStorage();
List _equalizerOptions = [];
@override
void initState() {
super.initState();
if (_templateData['value'].isNotEmpty) {
_values = List.from(_templateData['value']);
}
_equalizerOptions = _storage.read('equalizer_options') ?? [];
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
appBar: _buildAppBar(isDarkMode),
body: SafeArea(
top: false,
child: _buildBody(isDarkMode),
),
);
}
// 构建应用栏
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
return AppBar(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0.5,
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
onPressed: () => Get.back(),
),
title: Text(
_templateData['name'],
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
centerTitle: true,
actions: [
GestureDetector(
onTap: () {
setState(() {
isEdit = !isEdit;
});
},
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 6.w,
),
child: Text(
isEdit ? 'cancel'.tr : 'edit'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 16.sp,
),
),
),
),
],
);
}
// 构建主体内容
Widget _buildBody(bool isDarkMode) {
bool isAdd = true;
return ListView(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w),
children: [
_equalizer(isDarkMode),
Wrap(
spacing: 10.w,
runSpacing: 10.w,
children: [
..._templateData['options'].map((Map option) {
bool isSelected = listEquals(_values, option['value']);
if (isSelected) {
isAdd = false;
}
return _item(isDarkMode, option, isSelected);
}).toList(),
if (isAdd)
GestureDetector(
onTap: () => _inputBottomSheet(isDarkMode),
child: Container(
width: 110.w,
height: 110.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isDarkMode
? Colors.white.withValues(alpha: 0.05)
: Colors.white,
borderRadius: BorderRadius.circular(8.w),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Icon(
Icons.add,
size: 40.sp,
color: Colors.green,
),
),
),
],
),
],
);
}
Widget _equalizer(bool isDarkMode) {
return Container(
height: 220.w,
margin: EdgeInsets.only(bottom: 5.w),
child: Row(
children: List.generate(
_templateData['xAxis'].length,
(index) => Expanded(
child: Stack(
children: [
RotatedBox(
quarterTurns: 3,
child: StatefulBuilder(
builder: (BuildContext context, setStateful) {
return Slider(
value: _values[index].toDouble(),
min: -5,
max: 5,
divisions: 10,
activeColor: Colors.green,
onChanged: (value) {
setStateful(() {
_values[index] = value.toInt();
});
},
onChangeEnd: (value) {
setState(() {});
},
);
},
),
),
Align(
alignment: Alignment.bottomCenter,
child: Text(
_templateData['xAxis'][index],
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.grey,
),
),
),
],
),
),
),
),
);
}
Widget _item(bool isDarkMode, Map option, bool isSelected) {
return GestureDetector(
onTap: () {
_templateData['value'] = option['value'];
Get.back(result: true);
},
child: Stack(
children: [
Container(
width: 110.w,
height: 110.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected
? Colors.green
: isDarkMode
? Colors.white.withValues(alpha: 0.05)
: Colors.white,
borderRadius: BorderRadius.circular(8.w),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Text(
option['label'],
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isSelected
? Colors.white
: isDarkMode
? Colors.white
: Colors.black,
),
),
),
if (isEdit && !option['default'])
Positioned(
top: 4.w,
right: 4.w,
child: GestureDetector(
onTap: () {
if (isSelected) {
_templateData['value'] = [];
_templateData['onCallback']([]);
}
_equalizerOptions.removeWhere(
(e) => listEquals(e['value'], option['value']));
_storage.write('equalizer_options', _equalizerOptions);
setState(() {
_templateData['options'].remove(option);
});
},
child: Container(
width: 24.w,
height: 24.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12.w),
),
child: Icon(
Icons.close,
size: 16.sp,
color: Colors.white,
),
),
),
),
],
),
);
}
void _inputBottomSheet(bool isDarkMode) {
Get.bottomSheet(
const SettingsInputWidget('添加均衡器', ''),
isScrollControlled: true,
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
).then((value) {
if (value != null) {
Map<String, Object> option = {
'label': value,
'value': List.from(_values),
'default': false,
};
_equalizerOptions.add(option);
_storage.write('equalizer_options', _equalizerOptions);
setState(() {
_templateData['options'].add(option);
});
}
});
}
}

231
apps/client/lib/modules/eq_settings/views/widgets/select_timer_widget.dart

@ -1,231 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
class SelectTimerWidget extends StatefulWidget {
final String title;
final int time;
const SelectTimerWidget(this.title, this.time, {super.key});
@override
State<SelectTimerWidget> createState() => _SelectTimerWidgetState();
}
class _SelectTimerWidgetState extends State<SelectTimerWidget> {
late FixedExtentScrollController _hourScrollController;
late FixedExtentScrollController _minuteScrollController;
int _hour = 0;
int _minute = 0;
@override
void initState() {
super.initState();
_hour = widget.time ~/ 60;
_minute = widget.time % 60;
_hourScrollController = FixedExtentScrollController(initialItem: _hour);
_minuteScrollController = FixedExtentScrollController(initialItem: _minute);
}
@override
void dispose() {
super.dispose();
_hourScrollController.dispose();
_minuteScrollController.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 10.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: TextStyle(
fontSize: 16.sp,
color: Colors.black87,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => Get.back(),
child: Container(
width: 26.w,
height: 26.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(26.r),
),
child: Icon(
Icons.close,
size: 18.w,
color: Colors.grey[400],
),
),
)
],
),
),
Divider(
height: 1,
color: Colors.grey[300],
),
SizedBox(
height: 220.w,
child: Row(
children: [
Expanded(
child: CupertinoPicker.builder(
itemExtent: 40.w,
diameterRatio: 1.1,
squeeze: 1.25,
childCount: 24,
selectionOverlay: _selectionOverlayWidget(
isRadiusLeft: true,
),
scrollController: _hourScrollController,
itemBuilder: (BuildContext context, int index) {
return Container(
padding: EdgeInsets.only(right: 10.w),
alignment: Alignment.centerRight,
child: Text(
'$index时',
style: _hour == index
? TextStyle(
color: const Color(0xFFFF5561),
fontSize: 15.sp,
fontWeight: FontWeight.w500,
)
: TextStyle(
color: const Color(0xFF9D9D9D),
fontSize: 15.sp,
),
),
);
},
onSelectedItemChanged: (int index) {
setState(() {
_hour = index;
});
},
),
),
Expanded(
child: CupertinoPicker.builder(
itemExtent: 40.w,
diameterRatio: 1.1,
squeeze: 1.25,
childCount: 60,
selectionOverlay: _selectionOverlayWidget(
isRadiusRight: true,
),
scrollController: _minuteScrollController,
itemBuilder: (BuildContext context, int index) {
return Container(
padding: EdgeInsets.only(left: 10.w),
alignment: Alignment.centerLeft,
child: Text(
'$index分',
style: _minute == index
? TextStyle(
color: const Color(0xFFFF5561),
fontSize: 15.sp,
fontWeight: FontWeight.w500,
)
: TextStyle(
color: const Color(0xFF9D9D9D),
fontSize: 15.sp,
),
),
);
},
onSelectedItemChanged: (int index) {
setState(() {
_minute = index;
});
},
),
),
],
),
),
Padding(
padding: EdgeInsets.only(bottom: 20.w),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'cancel'.tr, // 取消
style: TextStyle(
fontSize: 16.sp,
color: Colors.black,
),
),
),
),
),
10.horizontalSpace,
Expanded(
child: GestureDetector(
onTap: () {
int time = _hour * 60 + _minute;
Get.back(result: time);
},
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'confirm'.tr, // 确认
style: TextStyle(
fontSize: 16.sp,
color: Colors.white,
),
),
),
),
),
],
),
),
],
),
);
}
Widget _selectionOverlayWidget({
bool isRadiusLeft = false,
bool isRadiusRight = false,
}) {
return Container(
decoration: BoxDecoration(
color: const Color.fromARGB(45, 255, 85, 97),
borderRadius: BorderRadius.horizontal(
left: isRadiusLeft ? Radius.circular(8.w) : Radius.zero,
right: isRadiusRight ? Radius.circular(8.w) : Radius.zero,
),
),
);
}
}

162
apps/client/lib/modules/eq_settings/views/widgets/settings_input_widget.dart

@ -1,162 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
class SettingsInputWidget extends StatefulWidget {
final String title;
final String inputText;
const SettingsInputWidget(this.title, this.inputText, {super.key});
@override
State<SettingsInputWidget> createState() => _SettingsInputWidgetState();
}
class _SettingsInputWidgetState extends State<SettingsInputWidget> {
final TextEditingController _inputController = TextEditingController();
@override
void initState() {
super.initState();
_inputController.text = widget.inputText;
}
@override
void dispose() {
_inputController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 10.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: TextStyle(
fontSize: 16.sp,
color: Colors.black87,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => Get.back(),
child: Container(
width: 26.w,
height: 26.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(26.r),
),
child: Icon(
Icons.close,
size: 18.w,
color: Colors.grey[400],
),
),
)
],
),
),
Divider(
height: 1,
color: Colors.grey[300],
),
Container(
margin: EdgeInsets.symmetric(vertical: 20.w),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.w),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _inputController,
style: TextStyle(
color: Colors.black87,
fontSize: 16.sp,
),
decoration: InputDecoration(
hintText: 'maxCharacters'.tr,
hintStyle: TextStyle(
color: Colors.black54,
fontSize: 16.sp,
),
isDense: true,
fillColor: Colors.transparent,
border: InputBorder.none,
contentPadding: EdgeInsets.all(10.w),
),
),
),
Padding(
padding: EdgeInsets.only(bottom: 20.w),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'cancel'.tr, // 取消
style: TextStyle(
fontSize: 16.sp,
color: Colors.black,
),
),
),
),
),
10.horizontalSpace,
Expanded(
child: GestureDetector(
onTap: () {
if (_inputController.text.isNotEmpty) {
Get.back(result: _inputController.text);
}
},
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'confirm'.tr, // 确认
style: TextStyle(
fontSize: 16.sp,
color: Colors.white,
),
),
),
),
),
],
),
),
],
),
);
}
}

12
apps/client/lib/modules/home/bindings/home_binding.dart

@ -1,12 +0,0 @@
import 'package:get/get.dart';
import '../controllers/home_controller.dart';
import '../controllers/calendar_controller.dart';
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<HomeController>(() => HomeController());
// 首页日历面板控制器
Get.lazyPut<CalendarController>(() => CalendarController());
}
}

520
apps/client/lib/modules/home/views/home_view.dart

@ -1,520 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../controllers/home_controller.dart';
import '../../../routes/app_routes.dart';
import 'widgets/calendar/calendar_card.dart';
import 'widgets/calendar/calendar_task_list.dart';
import '../controllers/calendar_controller.dart';
// 首页视图
class HomeView extends GetView<HomeController> {
const HomeView({Key? key}) : super(key: key);
// 功能卡片模块(语音记录/翻译/通话录音等)折叠态下的目标可视高度:
// 固定预算而非内容自然高度,避免卡片变多时撑爆首页固定布局;
// 展示不下的部分(如通话录音)在模块内部滚动查看。
static const double _extrasTargetHeight = 420;
@override
Widget build(BuildContext context) {
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: Colors.transparent,
// 背景:顶部「左淡蓝·中浅蓝·右淡暖」横向晕染,向下渐隐到白(取自参考图真实色值)
body: Stack(
children: [
// 1) 顶部横向色带(浅色模式)/ 深色竖直渐变(深色模式)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: isDarkMode
? const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0A0F1A), Color(0xFF000000)],
)
: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFFD4EAFF), // 左:淡蓝
Color(0xFFE6F3FE), // 中:浅蓝
Color(0xFFFBF2F0), // 右:淡暖(浅奶粉)
],
),
),
),
),
// 2) 竖直方向把顶部色带渐隐到白(仅浅色模式)
if (!isDarkMode)
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x00FFFFFF), // 顶部透明:露出横向色带
Color(0x00FFFFFF),
Color(0xFFFFFFFF), // 向下渐变为白
],
stops: [0.0, 0.15, 0.42],
),
),
),
),
// 内容(固定布局:日历展开时任务列表铺满并覆盖下方翻译卡片/AI)
SafeArea(
child: Padding(
// 横向不留白:任务列表需铺满到屏幕边(在屏幕边裁切,阴影完整);
// 其余元素各自加 16 横向内边距对齐
padding: EdgeInsets.only(top: 16.w, bottom: 8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部标题栏(固定)
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildAppBar(context, isDarkMode),
),
SizedBox(height: 16.h),
// 日历 + 任务列表 + 翻译卡片/AI(随日历折叠/展开切换)
Expanded(child: _buildCalendarArea(isDarkMode)),
],
),
),
),
],
),
);
}
// 日历区域:日历卡片 + 任务列表(Expanded) + 翻译卡片/AI(随展开收起)
Widget _buildCalendarArea(bool isDarkMode) {
final cal = Get.find<CalendarController>();
return LayoutBuilder(
builder: (context, constraints) {
final double h = constraints.maxHeight;
// 极小高度(超大字体/超小屏)兜底:退回可滚动布局,避免溢出
if (h < 360.h) {
return _buildCalendarScrollFallback(isDarkMode);
}
// 月历网格最大高度夹取:留出间距 + 最小任务列表 + 卡片其余高度,避免 Expanded 负高
final double maxBody = h - 16.h - 96.h - 130.h;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: CalendarCard(maxBodyHeight: maxBody > 0 ? maxBody : null),
),
SizedBox(height: 16.h),
// 任务列表:填满任务区(连续高度,便于折叠/展开两层按下拉距离交叉淡入淡出)。
const Expanded(child: CalendarTaskList()),
// 翻译卡片 + AI:随日历展开(dragProgress→0)从底部收起、被覆盖。
// 用固定目标高度(非内容自然高度)做插值:卡片模块内容一旦变多(如新增
// "通话录音")就不会撑爆这个不滚动的整屏布局;模块内部改用可滚动容器
// 兜底——展示不下的卡片(如通话录音)靠上下滑动查看,而不是让布局溢出。
Obx(() => ClipRect(
child: SizedBox(
height: _extrasTargetHeight.h * cal.dragProgress.value,
child: IgnorePointer(
ignoring: cal.dragProgress.value < 1.0,
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 16.w),
physics: const BouncingScrollPhysics(),
child: _buildExtrasBlock(isDarkMode),
),
),
),
)),
],
);
},
);
}
// 翻译功能卡片(折叠态显示在任务列表下方;展开态被收起覆盖)
// AI 球已改为全局悬浮球(core/widgets/global_ai_orb.dart),不再嵌在首页里
Widget _buildExtrasBlock(bool isDarkMode) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 16.h),
_buildFeatureGrid(isDarkMode),
SizedBox(height: 24.h),
],
);
}
// 极小屏兜底:可滚动布局(任务列表给有限高度)
Widget _buildCalendarScrollFallback(bool isDarkMode) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: const CalendarCard(),
),
SizedBox(height: 16.h),
SizedBox(height: 280.h, child: const CalendarTaskList()),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildFeatureGrid(isDarkMode),
),
SizedBox(height: 24.h),
],
),
);
}
// 顶部标题栏:左侧设备管理,右侧“语音打断”开关 + 设置入口
Widget _buildAppBar(BuildContext context, bool isDarkMode) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 左侧:设备管理入口
_buildDeviceManager(isDarkMode),
// 右侧:标语 + 设置图标
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'YM开启科技人生',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white70 : Colors.grey[700],
),
),
SizedBox(width: 8.w),
_buildSettingsEntry(isDarkMode),
],
),
],
);
}
// 左侧设备管理:未连接显示“添加设备”,已连接显示电量
Widget _buildDeviceManager(bool isDarkMode) {
final Color pillBg = isDarkMode ? const Color(0xFF1A1A1A) : Colors.white;
final Color textColor = isDarkMode ? Colors.white : const Color(0xFF222222);
return Obx(() {
final bool connected = controller.isFullyConnectedRx.value;
return GestureDetector(
behavior: HitTestBehavior.opaque,
// 未连接 → 扫描页;已连接 → 我的设备
onTap: () => Get.toNamed(connected ? Routes.myDevices : Routes.devices),
child: Container(
padding: EdgeInsets.fromLTRB(6.w, 6.h, 14.w, 6.h),
decoration: BoxDecoration(
color: pillBg,
borderRadius: BorderRadius.circular(24.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 蓝色圆形图标
Container(
width: 26.w,
height: 26.w,
decoration: const BoxDecoration(
color: Color(0xFF3B82F6),
shape: BoxShape.circle,
),
child: Icon(
connected ? Icons.battery_full : Icons.add,
color: Colors.white,
size: connected ? 16.sp : 18.sp,
),
),
SizedBox(width: 8.w),
Text(
connected ? controller.getBatteryText() : 'addDevice'.tr,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: textColor,
),
),
],
),
),
);
});
}
// 右侧“语音打断”开关(保留原有逻辑与样式)
// 设置入口:头像/人形图标,点击进入设置页
Widget _buildSettingsEntry(bool isDarkMode) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Get.toNamed(Routes.settings),
child: Container(
width: 36.w,
height: 36.w,
decoration: BoxDecoration(
color: isDarkMode
? const Color(0xFF2A2A2A)
: Colors.white.withOpacity(0.7),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.08),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.person,
size: 22.sp,
color: isDarkMode ? Colors.white70 : Colors.grey[700],
),
),
);
}
// 功能卡片区:顶部(通话录音/现场录音,依赖设备连接) + 左1大(语音记录助手) +
// 右2(同声翻译/面对面翻译) + 下2(通话翻译/多媒体翻译)
Widget _buildFeatureGrid(bool isDarkMode) {
const double smallH = 84; // 小卡高度
const double gap = 12; // 卡片间距
return Column(
children: [
// 顶区:通话录音 + 现场录音(依赖设备连接,未连接时置灰/透明)
Obx(() {
final bool connected = controller.isFullyConnectedRx.value;
return Row(
children: [
Expanded(
child: _buildFeatureCard(
title: '通话录音',
icon: Icons.fiber_manual_record,
gradient: const [Color(0xFFEF5350), Color(0xFFE53935)],
height: smallH.h,
isDarkMode: isDarkMode,
enabled: connected,
onTap: controller.openCallRecording,
),
),
SizedBox(width: gap.w),
Expanded(
child: _buildFeatureCard(
title: '现场录音',
icon: Icons.mic,
gradient: const [Color(0xFF26C6DA), Color(0xFF00838F)],
height: smallH.h,
isDarkMode: isDarkMode,
onTap: controller.openFieldRecording,
),
),
],
);
}),
SizedBox(height: gap.h),
// 上区:左侧高卡 + 右侧两张卡
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildFeatureCard(
title: '语音记录助手',
subtitle: '实时记录各种场景语音',
icon: Icons.groups_2,
gradient: const [Color(0xFFAB47BC), Color(0xFFEC407A)],
height: (smallH * 2 + gap).h,
iconOnTop: true,
isDarkMode: isDarkMode,
onTap: controller.openMeet, // → 会议(Routes.meeting)
),
),
SizedBox(width: gap.w),
Expanded(
child: Column(
children: [
_buildFeatureCard(
title: '同声翻译',
icon: Icons.record_voice_over,
gradient: const [Color(0xFF4CAF50), Color(0xFF009688)],
height: smallH.h,
isDarkMode: isDarkMode,
onTap: () =>
controller.openTranslationFeature('simultaneous'),
),
SizedBox(height: gap.h),
_buildFeatureCard(
title: '面对面翻译',
icon: Icons.forum,
gradient: const [Color(0xFF42A5F5), Color(0xFF1E88E5)],
height: smallH.h,
isDarkMode: isDarkMode,
onTap: () =>
controller.openTranslationFeature('faceToFace'),
),
],
),
),
],
),
SizedBox(height: gap.h),
// 下区:通话翻译 + 多媒体翻译(依赖设备连接,未连接时置灰/透明)
Obx(() {
final bool connected = controller.isFullyConnectedRx.value;
return Row(
children: [
Expanded(
child: _buildFeatureCard(
title: '通话翻译',
icon: Icons.phone_in_talk,
gradient: const [Color(0xFFFFA726), Color(0xFFFB8C00)],
height: smallH.h,
isDarkMode: isDarkMode,
enabled: connected,
onTap: () => controller.openTranslationFeature('call'),
),
),
SizedBox(width: gap.w),
Expanded(
child: _buildFeatureCard(
title: '多媒体翻译',
icon: Icons.photo_camera,
gradient: const [Color(0xFF5C6BC0), Color(0xFF3949AB)],
height: smallH.h,
isDarkMode: isDarkMode,
onTap: () => controller.openTranslationFeature('audioVideo'),
),
),
],
);
}),
],
);
}
// 通用功能卡片
Widget _buildFeatureCard({
required String title,
String? subtitle,
required IconData icon,
required List<Color> gradient,
required double height,
required bool isDarkMode,
required VoidCallback onTap,
bool iconOnTop = false,
bool enabled = true, // false 时整卡变透明(未连接设备)
}) {
final Color cardBg = isDarkMode ? const Color(0xFF1A1A1A) : Colors.white;
final Color titleColor =
isDarkMode ? Colors.white : const Color(0xFF222222);
final Color subColor =
isDarkMode ? Colors.white60 : const Color(0xFF888888);
final Widget iconBox = Container(
width: 42.w,
height: 42.w,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: gradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12.r),
),
child: Icon(icon, color: Colors.white, size: 22.sp),
);
return Opacity(
opacity: enabled ? 1.0 : 0.4, // 未连接:整卡变透明(仍可点击 → 弹"请连接设备")
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: height,
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: cardBg,
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.06 : 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: iconOnTop
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
iconBox,
const Spacer(),
Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
if (subtitle != null) ...[
SizedBox(height: 4.h),
Text(
subtitle,
style: TextStyle(fontSize: 12.sp, color: subColor),
),
],
],
)
: Row(
children: [
iconBox,
SizedBox(width: 12.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
if (subtitle != null) ...[
SizedBox(height: 2.h),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style:
TextStyle(fontSize: 11.sp, color: subColor),
),
],
],
),
),
],
),
),
),
);
}
}

242
apps/client/lib/modules/meeting/utils/jieli_call_recorder.dart

@ -1,242 +0,0 @@
// 通话录音 Dart 端 PCM 写入器(设备录音通路 / stereo)
//
// 早先曾走过两版:
// 1) deviceRecordStart (MODE_CALL_TRANSLATION + ch=1, ALWAYS_RECORDING):
// 监听 uplink + downlink 两路 mono 配对写 stereo WAV。
// 问题:那条路是为真实 SCO 电话通话准备的,没有通话期间设备不会推任何
// PCM,本地测试无法采样。
// 2) assistantStart (MODE_RECORD + ch=1, ALWAYS_RECORDING):
// 只能拿到本端单声道,对端音丢失,无法满足"会议记录"对双方音轨的需求。
//
// 当前实现回到 deviceRecordStart,但 native 已经把 mode 切到
// MODE_CALL_TRANSLATION_WITH_STEREO + ch=2 + STRATEGY_DEVICE_ALWAYS_RECORDING。
// 耳机持续上推 SOURCE_E_SCO_MIX 双声道交织 OPUS(与真实通话状态解耦),
// 由 device_jieli 解码成 16k/16bit/stereo PCM(L=本端 R=对端)后,按
// [DeviceRecordAudioEvent] 推到 Dart。本类直接写成标准 stereo WAV。
//
// 注意:device_jieli 设备录音通路无原生 pause API,本类的暂停语义通过
// 在 Dart 层丢弃接收到的 PCM 帧实现。
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_jieli/device_jieli.dart';
import '/core/utils/logger.dart';
class JieliCallRecorder {
JieliCallRecorder({
this.sampleRate = 16000,
this.bitsPerSample = 16,
this.onWavePcm,
});
/// 采样率(Hz)。device_jieli 设备录音通路固定 16000。
final int sampleRate;
/// 位深,固定 16bit。
final int bitsPerSample;
/// 写入文件后的 stereo PCM 通知(与 WAV 同步),用于驱动上层波形/振幅。
/// payload 是 stereo 交织 PCM,调用方如需做 mono 振幅可自取一边或求平均。
final void Function(Uint8List pcm)? onWavePcm;
static const String _tag = 'JieliCallRecorder';
static const int _channels = 2; // stereo: L=本端 R=对端
StreamSubscription<JieliEvent>? _eventSub;
RandomAccessFile? _raf;
String? _filePath;
int _dataBytes = 0;
bool _paused = false;
bool _running = false;
bool get isRunning => _running;
String? get filePath => _filePath;
/// 启动录音:写 WAV 头占位、订阅事件、调起设备录音通路。
Future<bool> start(String filePath) async {
if (_running) {
Logger.w(_tag, '重复 start 调用,已忽略');
return false;
}
try {
final file = File(filePath);
final dir = file.parent;
if (!await dir.exists()) {
await dir.create(recursive: true);
}
if (await file.exists()) {
await file.delete();
}
_raf = await file.open(mode: FileMode.write);
await _raf!.writeFrom(_makeWavHeader(0));
_dataBytes = 0;
_filePath = filePath;
_paused = false;
_eventSub = Jielihome.instance.events.listen(_onEvent);
try {
// 防御性先 stop,避免外部已有录音通路在跑导致 start 行为不确定
try {
await Jielihome.instance.deviceRecordStop();
} catch (_) {}
await Jielihome.instance.deviceRecordStart(sampleRate: sampleRate);
} catch (e) {
Logger.error('$_tag deviceRecordStart 失败: $e');
await _shutdown(saveFile: false);
return false;
}
_running = true;
Logger.info(
'$_tag 启动通话录音 path=$filePath sr=$sampleRate ch=$_channels (设备录音通路 / stereo)');
return true;
} catch (e) {
Logger.error('$_tag 启动失败: $e');
await _shutdown(saveFile: false);
return false;
}
}
/// 暂停:仅在 Dart 层丢帧;设备端继续推流。
void pause() {
if (!_running) return;
_paused = true;
}
/// 恢复接收设备端帧。
void resume() {
if (!_running) return;
_paused = false;
}
/// 停止录音;[save] 为 false 时回滚删除文件。
Future<bool> stop({required bool save}) async {
if (!_running) {
// 即便外部异常重复 stop,也保证幂等
await _shutdown(saveFile: save);
return true;
}
_running = false;
try {
try {
await Jielihome.instance.deviceRecordStop();
} catch (e) {
Logger.w(_tag, 'deviceRecordStop 失败(忽略): $e');
}
await _shutdown(saveFile: save);
return true;
} catch (e) {
Logger.error('$_tag 停止失败: $e');
return false;
}
}
Future<void> _shutdown({required bool saveFile}) async {
await _eventSub?.cancel();
_eventSub = null;
final raf = _raf;
final path = _filePath;
_raf = null;
_filePath = null;
_paused = false;
if (raf != null) {
try {
if (saveFile) {
await raf.setPosition(0);
await raf.writeFrom(_makeWavHeader(_dataBytes));
}
} catch (e) {
Logger.error('$_tag 写 WAV 头失败: $e');
} finally {
try {
await raf.flush();
} catch (_) {}
try {
await raf.close();
} catch (_) {}
}
}
if (!saveFile && path != null) {
try {
final f = File(path);
if (await f.exists()) {
await f.delete();
}
} catch (e) {
Logger.w(_tag, '删除录音文件失败: $e');
}
}
_dataBytes = 0;
}
void _onEvent(JieliEvent event) {
if (event is DeviceRecordAudioEvent) {
if (_paused) return;
if (event.pcm.isEmpty) return;
_writePcm(event.pcm);
} else if (event is DeviceRecordErrorEvent) {
Logger.error(
'$_tag 设备录音通路错误: code=${event.code} msg=${event.message ?? ''}');
}
}
void _writePcm(Uint8List pcm) {
final raf = _raf;
if (raf == null) return;
try {
raf.writeFromSync(pcm);
_dataBytes += pcm.length;
} catch (e) {
Logger.error('$_tag 写 PCM 失败: $e');
return;
}
onWavePcm?.call(pcm);
}
/// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。
/// 头里的 channels=2 / blockAlign=4 反映 stereo 交织 PCM 格式。
Uint8List _makeWavHeader(int dataLength) {
final byteRate = sampleRate * _channels * (bitsPerSample ~/ 8);
final blockAlign = _channels * (bitsPerSample ~/ 8);
final totalLength = 36 + dataLength;
final h = ByteData(44);
// "RIFF"
h.setUint8(0, 0x52);
h.setUint8(1, 0x49);
h.setUint8(2, 0x46);
h.setUint8(3, 0x46);
h.setUint32(4, totalLength, Endian.little);
// "WAVE"
h.setUint8(8, 0x57);
h.setUint8(9, 0x41);
h.setUint8(10, 0x56);
h.setUint8(11, 0x45);
// "fmt "
h.setUint8(12, 0x66);
h.setUint8(13, 0x6d);
h.setUint8(14, 0x74);
h.setUint8(15, 0x20);
h.setUint32(16, 16, Endian.little); // fmt chunk size
h.setUint16(20, 1, Endian.little); // PCM
h.setUint16(22, _channels, Endian.little);
h.setUint32(24, sampleRate, Endian.little);
h.setUint32(28, byteRate, Endian.little);
h.setUint16(32, blockAlign, Endian.little);
h.setUint16(34, bitsPerSample, Endian.little);
// "data"
h.setUint8(36, 0x64);
h.setUint8(37, 0x61);
h.setUint8(38, 0x74);
h.setUint8(39, 0x61);
h.setUint32(40, dataLength, Endian.little);
return h.buffer.asUint8List();
}
}

11
apps/client/lib/modules/music/bindings/music_playlist_binding.dart

@ -1,11 +0,0 @@
import 'package:get/get.dart';
import '../controllers/music_playlist_controller.dart';
class MusicPlaylistBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<MusicPlaylistController>(
() => MusicPlaylistController(),
);
}
}

14
apps/client/lib/modules/music/controllers/music_playlist_controller.dart

@ -1,14 +0,0 @@
import 'package:get/get.dart';
class MusicPlaylistController extends GetxController {
// 歌单数据
final playlist = <Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
// 从路由参数获取歌单数据
final List<Map<String, dynamic>> playlistData = Get.arguments ?? [];
playlist.assignAll(playlistData);
}
}

724
apps/client/lib/modules/music/views/music_playlist_view.dart

@ -1,724 +0,0 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../core/utils/logger.dart';
import '../../agent/controllers/agent_controller.dart';
import '../../agent/widgets/circular_progress_button.dart';
class MusicPlaylistView extends StatelessWidget {
const MusicPlaylistView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
ScreenUtil.init(context, designSize: const Size(375, 812)); // 添加设计尺寸参考
final controller = Get.find<AgentController>(); // 获取控制器
final List<Map<String, dynamic>> playlist = Get.arguments ?? [];
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 20), // iOS风格箭头
onPressed: () => Navigator.pop(context),
),
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
foregroundColor: isDarkMode ? Colors.white : Colors.black,
elevation: 0,
),
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
body: playlist.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_off,
size: 64.w,
color: Colors.grey[400],
),
SizedBox(height: 16.h),
Text(
'noMusic'.tr, // 暂无音乐
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey[600],
),
),
],
),
)
: Column(
children: [
// 上部:当前播放音乐详情 - 响应式高度
Container(
height: MediaQuery.of(context).size.height * 0.38, // 增大高度比例
child: _buildCurrentPlayingSection(
controller, playlist, isDarkMode),
),
// 中部:播放全部按钮
_buildPlayAllSection(controller, playlist, isDarkMode),
// 下部:歌单列表 - 使用剩余空间
Expanded(
child:
_buildPlaylistSection(controller, playlist, isDarkMode),
),
],
),
);
}
// 构建当前播放音乐详情部分
Widget _buildCurrentPlayingSection(AgentController controller,
List<Map<String, dynamic>> playlist, bool isDarkMode) {
return SingleChildScrollView(
// 添加滚动支持
child: Obx(() {
// 查找当前播放的歌曲
Map<String, dynamic>? currentSong;
if (controller.currentId.value != 0) {
try {
currentSong = playlist.firstWhere(
(song) => song['id'] == controller.currentId.value,
);
} catch (e) {
// 如果没找到,使用第一首歌曲
currentSong = playlist.isNotEmpty ? playlist.first : null;
}
} else {
currentSong = playlist.isNotEmpty ? playlist.first : null;
}
if (currentSong == null) {
return Container(
padding: EdgeInsets.all(16.w),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 60.w,
color: Colors.grey[400],
),
SizedBox(height: 12.h),
Text(
'noPlayingMusic'.tr, // 暂无播放音乐
style: TextStyle(
fontSize: 16.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
),
);
}
final title = currentSong['name'] ??
currentSong['title'] ??
'unknownSong'.tr; // 未知歌曲
final artist = currentSong['singer'] ??
currentSong['sgener'] ??
currentSong['artist'] ??
'unknownArtist'.tr; // 未知歌手
final coverUrl = currentSong['image'] ?? currentSong['coverUrl'] ?? '';
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 封面图片 - 响应式尺寸
_buildCoverImage(coverUrl, isDarkMode, size: 110.w),
SizedBox(height: 12.h),
// 歌曲信息
_buildSongInfo(title, artist, isDarkMode),
SizedBox(height: 16.h),
// 进度条
_buildProgressBar(controller, isDarkMode),
SizedBox(height: 15.h),
// 播放控制按钮
_buildPlayControls(controller, playlist, isDarkMode),
],
),
);
}),
);
}
// 构建播放全部按钮部分
Widget _buildPlayAllSection(AgentController controller,
List<Map<String, dynamic>> playlist, bool isDarkMode) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton.icon(
onPressed: () async {
if (playlist.isNotEmpty) {
await controller.playPlaylist(playlist);
}
},
icon: Icon(Icons.play_arrow, color: Colors.white, size: 20.w),
label: Text(
'playAll'.tr, // 播放全部
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(Get.context!).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.r),
),
elevation: 3,
shadowColor: Theme.of(Get.context!).primaryColor.withAlpha(102),
),
),
),
);
}
// 构建歌单列表部分
Widget _buildPlaylistSection(AgentController controller,
List<Map<String, dynamic>> playlist, bool isDarkMode) {
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h),
itemCount: playlist.length,
itemBuilder: (context, index) {
final item = playlist[index];
final id = item["id"] ?? 0;
final name = item["name"] ?? 'unknownSong'.tr; // 未知歌曲
final singer =
item["singer"] ?? item["sgener"] ?? 'unknownArtist'.tr; // 未知歌手
final image = item["image"] ?? "";
return _buildMusicItem(
context,
controller,
id,
name,
singer,
image,
isDarkMode,
);
},
);
}
Widget _buildMusicItem(
BuildContext context,
AgentController controller,
int id,
String name,
String singer,
String image,
bool isDarkMode,
) {
final song = {
'id': id,
'title': name,
'artist': singer,
'coverUrl': image,
};
return GestureDetector(
onTap: () async {
await _playSelectedSong(controller, song, Get.arguments ?? []);
},
child: Container(
margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.white,
borderRadius: BorderRadius.circular(14.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 封面图片
Container(
width: 52.w,
height: 52.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.r),
color: isDarkMode ? Colors.grey[700] : Colors.grey[200],
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.08),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Stack(
children: [
image.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(10.r),
child: Image.network(
image,
width: 52.w,
height: 52.w,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.music_note,
color: isDarkMode
? Colors.grey[500]
: Colors.grey[600],
size: 24.w,
);
},
),
)
: Icon(
Icons.music_note,
color:
isDarkMode ? Colors.grey[500] : Colors.grey[600],
size: 24.w,
),
// 右下角音乐图标
if (image.isNotEmpty)
Positioned(
right: 2.w,
bottom: 2.w,
child: Container(
width: 14.w,
height: 14.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 1,
offset: const Offset(0, 0.5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
),
],
),
),
SizedBox(width: 14.w),
// 歌曲信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
name,
style: TextStyle(
fontSize: 15.5.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 3.h),
Text(
singer,
style: TextStyle(
fontSize: 13.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
height: 1.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(width: 14.w),
// 播放按钮
Obx(() {
final currentIdInt = id;
final isThisPlaying = controller.isPlaying.value &&
controller.currentId.value == currentIdInt;
final currentProgress =
isThisPlaying ? controller.progress.value : 0.0;
return CircularProgressButton(
isPlaying: isThisPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 40.w,
strokeWidth: 2.5,
onPlayPause: () async {
try {
Logger.d('MusicPlaylistView',
'播放按钮被点击,当前播放状态: ${controller.isPlaying.value}');
Logger.d('MusicPlaylistView',
'当前歌曲ID: ${controller.currentId.value}, 点击歌曲ID: $currentIdInt');
if (controller.isPlaying.value) {
if (controller.currentId.value == currentIdInt) {
Logger.d('MusicPlaylistView', '暂停当前播放的歌曲');
await controller.musicPause();
} else {
Logger.d('MusicPlaylistView', '播放新歌曲');
await controller.musicPlay(song);
}
} else {
Logger.d('MusicPlaylistView', '开始播放歌曲');
await controller.musicPlay(song);
}
} catch (e) {
Logger.e('MusicPlaylistView', '播放音乐时发生错误: $e');
}
},
onSeek: isThisPlaying
? (seekProgress) {
// 只有当前播放的歌曲才允许跳转
final targetPosition =
(controller.duration.value * seekProgress).round();
controller.seekToPosition(targetPosition);
}
: null,
);
}),
],
),
),
);
}
// 构建封面图片
Widget _buildCoverImage(String coverUrl, bool isDarkMode,
{required double size}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
blurRadius: 15,
spreadRadius: 2,
offset: const Offset(0, 6),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: coverUrl.isNotEmpty
? Image.network(
coverUrl,
fit: BoxFit.cover,
width: size,
height: size,
errorBuilder: (context, error, stackTrace) {
return _buildDefaultCover(isDarkMode, size: size);
},
)
: _buildDefaultCover(isDarkMode, size: size),
),
// 右下角音乐图标
Positioned(
right: 4.w,
bottom: 4.w,
child: Container(
width: size * 0.25,
height: size * 0.25,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
),
],
),
);
}
// 构建默认封面
Widget _buildDefaultCover(bool isDarkMode, {required double size}) {
return Container(
color: isDarkMode ? Colors.grey[800] : Colors.grey[300],
child: Icon(
Icons.music_note,
size: size * 0.55,
color: isDarkMode ? Colors.grey[600] : Colors.grey[500],
),
);
}
// 构建歌曲信息
Widget _buildSongInfo(String title, String artist, bool isDarkMode) {
return Column(
children: [
Text(
title,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 6.h),
Text(
artist,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
// 构建进度条
Widget _buildProgressBar(AgentController controller, bool isDarkMode) {
return Obx(() {
return Row(
children: [
SizedBox(
width: 40.w,
child: Text(
_formatDuration(controller.currentPosition.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
fontFeatures: const [FontFeature.tabularFigures()],
),
textAlign: TextAlign.left,
),
),
SizedBox(width: 8.w),
Expanded(
child: SliderTheme(
data: SliderThemeData(
activeTrackColor: Theme.of(Get.context!).primaryColor,
inactiveTrackColor:
isDarkMode ? Colors.grey[700] : Colors.grey[300],
thumbColor: Theme.of(Get.context!).primaryColor,
overlayColor:
Theme.of(Get.context!).primaryColor.withOpacity(0.2),
trackHeight: 4.h,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: controller.progress.value.clamp(0.0, 1.0),
onChanged: (value) {
// 拖动中仅更新UI显示,不触发实际跳转
controller.progress.value = value;
controller.currentPosition.value =
(controller.duration.value * value).round();
},
onChangeEnd: (value) {
// 松开时再执行跳转,确保拖动自然
final targetPosition =
(controller.duration.value * value).round();
controller.seekToPosition(targetPosition);
},
),
),
),
SizedBox(width: 8.w),
SizedBox(
width: 40.w,
child: Text(
_formatDuration(controller.duration.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
fontFeatures: const [FontFeature.tabularFigures()],
),
textAlign: TextAlign.right,
),
),
],
);
});
}
// 构建播放控制按钮
Widget _buildPlayControls(AgentController controller,
List<Map<String, dynamic>> playlist, bool isDarkMode) {
return Obx(() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () async => await _playPrevious(controller, playlist),
iconSize: 32.w,
padding: EdgeInsets.all(8.w), // 增大触摸区域
icon: Icon(
Icons.skip_previous,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
SizedBox(width: 16.w),
Container(
width: 56.w,
height: 56.w,
decoration: BoxDecoration(
color: Theme.of(Get.context!).primaryColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Theme.of(Get.context!).primaryColor.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 1,
offset: const Offset(0, 3),
),
],
),
child: IconButton(
onPressed: () => _togglePlayPause(controller),
padding: EdgeInsets.zero,
icon: Icon(
controller.isPlaying.value ? Icons.pause : Icons.play_arrow,
size: 28.w,
color: Colors.white,
),
),
),
SizedBox(width: 16.w),
IconButton(
onPressed: () async => await _playNext(controller, playlist),
iconSize: 32.w,
padding: EdgeInsets.all(8.w), // 增大触摸区域
icon: Icon(
Icons.skip_next,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
],
);
});
}
// ...(其他辅助方法保持不变,如_formatDuration, _togglePlayPause等)
// 格式化时间显示
String _formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
// 播放/暂停切换
Future<void> _togglePlayPause(AgentController controller) async {
if (controller.isPlaying.value) {
await controller.musicPause();
} else {
await controller.musicResume();
}
}
// 播放上一曲
Future<void> _playPrevious(
AgentController controller, List<Map<String, dynamic>> playlist) async {
// 防止重复点击导致的状态混乱
if (controller.isLoadingMusic.value) {
return;
}
try {
// 设置加载状态,防止重复操作
controller.isLoadingMusic.value = true;
// 更新播放列表上下文
controller.updateCurrentPlaylistContext(
playlist, controller.currentId.value);
// 执行上一曲操作
await controller.playPrevious();
} finally {
// 确保加载状态被重置
controller.isLoadingMusic.value = false;
}
}
// 播放下一曲
Future<void> _playNext(
AgentController controller, List<Map<String, dynamic>> playlist) async {
// 防止重复点击导致的状态混乱
if (controller.isLoadingMusic.value) {
return;
}
try {
// 设置加载状态,防止重复操作
controller.isLoadingMusic.value = true;
// 更新播放列表上下文
controller.updateCurrentPlaylistContext(
playlist, controller.currentId.value);
// 执行下一曲操作
await controller.playNext();
} finally {
// 确保加载状态被重置
controller.isLoadingMusic.value = false;
}
}
// 播放选中的歌曲
Future<void> _playSelectedSong(AgentController controller,
Map<String, dynamic> song, List<Map<String, dynamic>> playlist) async {
// 防止重复点击导致的状态混乱
if (controller.isLoadingMusic.value) {
return;
}
try {
// 设置加载状态,防止重复操作
controller.isLoadingMusic.value = true;
final songId = song['id'] ?? 0;
controller.updateCurrentPlaylistContext(playlist, songId);
await controller.musicPlay(song);
} finally {
// 确保加载状态被重置
controller.isLoadingMusic.value = false;
}
}
}

9
apps/client/lib/modules/opus_test/bindings/opus_test_binding.dart

@ -1,9 +0,0 @@
import 'package:get/get.dart';
import '../controllers/opus_test_controller.dart';
class OpusTestBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<OpusTestController>(() => OpusTestController());
}
}

676
apps/client/lib/modules/opus_test/controllers/opus_test_controller.dart

@ -1,676 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import 'package:just_audio/just_audio.dart';
import 'package:jl_opus/jl_opus.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../core/utils/permission_util.dart';
import 'package:share_plus/share_plus.dart'; // 添加分享插件导入
class OpusTestController extends GetxController {
// 选中的文件
final selectedFiles = <PlatformFile>[].obs;
// 扫描到的外部存储文件
final externalFiles = <PlatformFile>[].obs;
// 是否携带协议头
final hasHeader = false.obs;
// 数据包长度
final packetLength = '80'.obs;
// 采样率选项
final sampleRateOptions = [8000, 16000, 24000, 32000, 44100, 48000];
final selectedSampleRate = 16000.obs;
// 通道数量
final isMono = true.obs;
// 播放状态
final isPlaying = false.obs;
final isDecoding = false.obs;
final statusMessage = ''.obs;
// 播放器
final player = AudioPlayer();
// 临时文件路径
String? tempPcmPath;
// 杰理OPUS解码器
JlOpus? jlOpus;
StreamSubscription? _eventSubscription;
static const MethodChannel _bleServiceChannel =
MethodChannel('com.yunqiinnovation.ble_service');
@override
void onInit() {
super.onInit();
// 合规(场景 1/2):不在 onInit 里申请权限——进页面就弹框,且拒绝后每次
// 重进/重启还会再弹。改为用户点击"扫描存储"时按需申请。
_initOpusDecoder();
}
@override
void onClose() {
player.dispose();
_eventSubscription?.cancel();
jlOpus?.dispose();
super.onClose();
}
// 初始化OPUS解码器
Future<void> _initOpusDecoder() async {
if (!Platform.isAndroid) {
return;
}
jlOpus = JlOpus();
// 监听解码器事件
_eventSubscription = jlOpus!.eventStream.listen((event) {
switch (event.event) {
case 'onStart':
statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...';
break;
case 'onComplete':
if (event.type == 'file') {
statusMessage.value = '解码完成: ${event.filePath}';
} else {
statusMessage.value = '流解码完成';
}
break;
case 'onError':
statusMessage.value =
'解码错误: [${event.errorCode}] ${event.errorMessage}';
isDecoding.value = false;
break;
}
});
// 初始化OPUS解码器
final initialized = await jlOpus!.initOpusDecoder();
if (!initialized) {
statusMessage.value = 'OPUS解码器初始化失败';
}
}
Future<String?> _decodeOpusFile(
String inPath, String outPath, OpusOption option) async {
if (Platform.isIOS) {
final result = await _bleServiceChannel.invokeMethod<String>(
'decodeOpusFile',
{
'inPath': inPath,
'outPath': outPath,
...option.toMap(),
},
);
return result;
}
return jlOpus?.decodeOpusFile(inPath, outPath, option);
}
// 请求必要权限(仅在用户主动触发读取存储的操作时调用)
//
// 合规(场景 1/2):统一走 PermissionUtil,拒绝一次后 48 小时内不再申请,
// 返回 false 时直接放弃本次操作,不弹引导框、不跳设置页。
Future<bool> requestPermissions() async {
if (!Platform.isAndroid) return true;
return PermissionUtil.instance.requestPermission(
permissionType: Permission.storage,
permissionName: 'storagePermission'.tr,
explanationText: 'storagePermissionExplanation'.tr,
permanentDenialText: 'storagePermissionExplanation'.tr,
);
}
// 加载外部存储文件
Future<void> loadExternalFiles() async {
// 用户主动触发,此时才申请存储权限;拿不到就直接退出该功能
if (!await requestPermissions()) {
statusMessage.value = 'storagePermissionExplanation'.tr;
return;
}
try {
externalFiles.clear();
statusMessage.value = '正在扫描存储...';
print('正在扫描存储...');
List<Directory> directories = [];
if (Platform.isAndroid) {
// Android: 使用外部存储目录
final androidDirs = await getExternalStorageDirectories();
if (androidDirs != null && androidDirs.isNotEmpty) {
directories.addAll(androidDirs);
}
} else if (Platform.isIOS) {
// iOS: 使用Documents目录
final iosDir = await getApplicationDocumentsDirectory();
directories.add(iosDir);
}
if (directories.isEmpty) {
statusMessage.value = '未找到存储目录';
return;
}
print('存储目录: $directories');
// 遍历所有存储目录
for (final directory in directories) {
final dirPath = directory.path;
final dir = Directory(dirPath);
// 检查目录是否存在
if (await dir.exists()) {
// 递归列出所有文件
final files = await dir.list(recursive: true).toList();
for (var entity in files) {
if (entity is File) {
final path = entity.path.toLowerCase();
if (path.endsWith('.opus') || path.endsWith('.txt')) {
final fileSize = await entity.length();
externalFiles.add(PlatformFile(
name: entity.path.split('/').last,
path: entity.path,
size: fileSize,
));
}
}
}
}
}
statusMessage.value = '扫描完成,找到 ${externalFiles.length} 个Opus文件';
} catch (e) {
statusMessage.value = '扫描失败: $e';
}
}
// 选择文件
Future<void> pickFiles() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['opus'],
allowMultiple: false,
);
if (result != null && result.files.isNotEmpty) {
selectedFiles.clear();
selectedFiles.add(result.files.first);
statusMessage.value = '已选择文件:${result.files.first.name}';
}
} catch (e) {
statusMessage.value = '选择文件失败: $e';
}
}
// 从外部文件列表中选择文件
void selectExternalFile(PlatformFile file) {
selectedFiles.clear();
selectedFiles.add(file);
statusMessage.value = '已选择文件: ${file.name}';
}
// 切换通道模式
void toggleChannel(bool isSingleChannel) {
isMono.value = isSingleChannel;
}
// 设置采样率
void setSampleRate(int rate) {
selectedSampleRate.value = rate;
}
// 设置数据包长度
void setPacketLength(String length) {
packetLength.value = length;
}
// 切换是否携带协议头
void toggleHeader(bool value) {
hasHeader.value = value;
}
// 播放音频
Future<void> playAudio() async {
if (isPlaying.value) {
await player.stop();
isPlaying.value = false;
statusMessage.value = '已停止播放';
return;
}
if (selectedFiles.isEmpty) {
statusMessage.value = '请先选择Opus文件';
return;
}
try {
isDecoding.value = true;
statusMessage.value = '正在解码...';
// 使用杰理OPUS库进行解码
final inPath = selectedFiles.first.path!;
// 创建WAV输出文件路径
final tempDir = await getTemporaryDirectory();
final wavFileName =
'decoded_opus_${DateTime.now().millisecondsSinceEpoch}.wav';
final outPath = '${tempDir.path}/$wavFileName';
// 创建解码参数
final option = OpusOption(
hasHeader: hasHeader.value,
channel: isMono.value ? 1 : 2,
sampleRate: selectedSampleRate.value,
packetSize: int.tryParse(packetLength.value) ?? 40,
);
// 解码文件
final pcmPath = await _decodeOpusFile(inPath, outPath, option);
if (pcmPath == null) {
statusMessage.value = '解码失败';
isDecoding.value = false;
return;
}
print("pcmPath: $pcmPath");
tempPcmPath = pcmPath;
// 将PCM文件转换为WAV文件
final wavFile = await _convertPcmToWav(
pcmPath,
'${tempDir.path}/$wavFileName',
selectedSampleRate.value,
isMono.value ? 1 : 2);
statusMessage.value = '解码完成,准备播放';
// 播放WAV文件
await player.setFilePath(wavFile.path);
await player.play();
isPlaying.value = true;
statusMessage.value = '正在播放解码后的音频';
// 监听播放完成
player.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
isPlaying.value = false;
statusMessage.value = '播放完成';
}
});
} catch (e) {
statusMessage.value = '解码或播放失败: $e';
} finally {
isDecoding.value = false;
}
}
// 将PCM转换为WAV格式
Future<File> _convertPcmToWav(
String pcmPath, String wavPath, int sampleRate, int channels) async {
final pcmFile = File(pcmPath);
final pcmData = await pcmFile.readAsBytes();
// 创建WAV文件 (44字节头部 + PCM数据)
final wavSize = 44 + pcmData.length;
final wavData = ByteData(wavSize);
// 写入WAV头部
_writeWavHeader(wavData, pcmData.length, sampleRate, channels);
// 写入PCM数据
for (int i = 0; i < pcmData.length; i++) {
wavData.setUint8(44 + i, pcmData[i]);
}
// 保存为WAV文件
final wavFile = File(wavPath);
await wavFile.writeAsBytes(wavData.buffer.asUint8List());
return wavFile;
}
// 写入WAV头部到ByteData
void _writeWavHeader(
ByteData data, int pcmLength, int sampleRate, int channels) {
final bytesPerSample = 2; // 16位PCM
final byteRate = sampleRate * channels * bytesPerSample;
final blockAlign = channels * bytesPerSample;
// "RIFF"标识
data.setUint8(0, 0x52); // 'R'
data.setUint8(1, 0x49); // 'I'
data.setUint8(2, 0x46); // 'F'
data.setUint8(3, 0x46); // 'F'
// 文件大小 (减去8个字节的RIFF头部和大小字段)
data.setUint32(4, 36 + pcmLength, Endian.little);
// "WAVE"标识
data.setUint8(8, 0x57); // 'W'
data.setUint8(9, 0x41); // 'A'
data.setUint8(10, 0x56); // 'V'
data.setUint8(11, 0x45); // 'E'
// "fmt "子块
data.setUint8(12, 0x66); // 'f'
data.setUint8(13, 0x6D); // 'm'
data.setUint8(14, 0x74); // 't'
data.setUint8(15, 0x20); // ' '
// 子块大小 (16 for PCM)
data.setUint32(16, 16, Endian.little);
// 音频格式 (1 表示PCM)
data.setUint16(20, 1, Endian.little);
// 通道数量
data.setUint16(22, channels, Endian.little);
// 采样率
data.setUint32(24, sampleRate, Endian.little);
// 字节率 = 采样率 * 通道数 * 每样本字节数
data.setUint32(28, byteRate, Endian.little);
// 块对齐 = 通道数 * 每样本字节数
data.setUint16(32, blockAlign, Endian.little);
// 每个样本位数
data.setUint16(34, bytesPerSample * 8, Endian.little);
// "data"子块
data.setUint8(36, 0x64); // 'd'
data.setUint8(37, 0x61); // 'a'
data.setUint8(38, 0x74); // 't'
data.setUint8(39, 0x61); // 'a'
// 数据大小
data.setUint32(40, pcmLength, Endian.little);
}
/// 删除外部文件
Future<void> deleteExternalFile(PlatformFile file) async {
try {
final fileToDelete = File(file.path!);
if (await fileToDelete.exists()) {
await fileToDelete.delete();
externalFiles.removeWhere((f) => f.path == file.path);
// 如果删除的是当前选中的文件,清空选择
if (selectedFiles.isNotEmpty && selectedFiles.first.path == file.path) {
selectedFiles.clear();
}
statusMessage.value = '文件已删除: ${file.name}';
} else {
statusMessage.value = '文件不存在: ${file.name}';
}
} catch (e) {
statusMessage.value = '删除文件失败: $e';
}
}
/// 分享外部文件
Future<void> shareExternalFile(PlatformFile file) async {
try {
if (file.path != null) {
final fileToShare = File(file.path!);
if (await fileToShare.exists()) {
await Share.shareXFiles(
[XFile(file.path!)],
text: '分享Opus音频文件: ${file.name}',
);
statusMessage.value = '正在分享文件: ${file.name}';
} else {
statusMessage.value = '文件不存在: ${file.name}';
}
}
} catch (e) {
statusMessage.value = '分享文件失败: $e';
}
}
/// 显示文件操作底部弹窗
void showFileActionSheet(BuildContext context, PlatformFile file) {
showModalBottomSheet(
context: context,
backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => Container(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 文件信息
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.audiotrack,
color: Get.isDarkMode ? Colors.white70 : Colors.black87,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Get.isDarkMode ? Colors.white : Colors.black,
),
overflow: TextOverflow.ellipsis,
),
Text(
'${(file.size / 1024).toStringAsFixed(2)} KB',
style: TextStyle(
fontSize: 14,
color: Get.isDarkMode
? Colors.white60
: Colors.black54,
),
),
],
),
),
],
),
),
const Divider(),
// 分享选项
ListTile(
leading: const Icon(Icons.share, color: Colors.blue),
title: const Text('分享文件'),
onTap: () {
Navigator.pop(context);
shareExternalFile(file);
},
),
// 删除选项
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('删除文件', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
_showDeleteConfirmDialog(context, file);
},
),
// 取消选项
ListTile(
leading: const Icon(Icons.cancel),
title: const Text('取消'),
onTap: () => Navigator.pop(context),
),
],
),
),
);
}
/// 显示删除确认对话框
void _showDeleteConfirmDialog(BuildContext context, PlatformFile file) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white,
title: Text(
'确认删除',
style: TextStyle(
color: Get.isDarkMode ? Colors.white : Colors.black,
),
),
content: Text(
'确定要删除文件 "${file.name}" 吗?\n此操作不可撤销。',
style: TextStyle(
color: Get.isDarkMode ? Colors.white70 : Colors.black87,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
deleteExternalFile(file);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('删除'),
),
],
),
);
}
}
// PCM音频源
class PCMAudioSource extends StreamAudioSource {
final Uint8List pcmData;
final int sampleRate;
final int channels;
PCMAudioSource({
required this.pcmData,
this.sampleRate = 16000,
this.channels = 1,
});
@override
Future<StreamAudioResponse> request([int? start, int? end]) async {
start = start ?? 0;
end = end ?? pcmData.length;
// 转换PCM到WAV格式
final wavHeader = _createWavHeader(pcmData.length, sampleRate, channels);
final wavData = Uint8List(wavHeader.length + pcmData.length);
wavData.setRange(0, wavHeader.length, wavHeader);
wavData.setRange(
wavHeader.length, wavHeader.length + pcmData.length, pcmData);
// 调整请求范围来适应WAV头部
int adjustedStart = start;
int adjustedEnd = end;
if (start < wavHeader.length) {
adjustedStart = start;
} else {
adjustedStart = start + wavHeader.length;
}
if (end <= wavHeader.length) {
adjustedEnd = end;
} else {
adjustedEnd = end + wavHeader.length;
adjustedEnd = adjustedEnd > wavData.length ? wavData.length : adjustedEnd;
}
return StreamAudioResponse(
sourceLength: wavData.length,
contentLength: adjustedEnd - adjustedStart,
offset: adjustedStart,
stream: Stream.value(wavData.sublist(adjustedStart, adjustedEnd)),
contentType: 'audio/wav',
);
}
// 创建WAV头部
Uint8List _createWavHeader(int dataLength, int sampleRate, int channels) {
final bytesPerSample = 2; // 16位PCM
final byteRate = sampleRate * channels * bytesPerSample;
final blockAlign = channels * bytesPerSample;
final header = ByteData(44); // WAV头部固定44字节
// "RIFF"标识
header.setUint8(0, 0x52); // 'R'
header.setUint8(1, 0x49); // 'I'
header.setUint8(2, 0x46); // 'F'
header.setUint8(3, 0x46); // 'F'
// 文件大小 (减去8个字节的RIFF头部和大小字段)
header.setUint32(4, 36 + dataLength, Endian.little);
// "WAVE"标识
header.setUint8(8, 0x57); // 'W'
header.setUint8(9, 0x41); // 'A'
header.setUint8(10, 0x56); // 'V'
header.setUint8(11, 0x45); // 'E'
// "fmt "子块
header.setUint8(12, 0x66); // 'f'
header.setUint8(13, 0x6D); // 'm'
header.setUint8(14, 0x74); // 't'
header.setUint8(15, 0x20); // ' '
// 子块大小 (16 for PCM)
header.setUint32(16, 16, Endian.little);
// 音频格式 (1 表示PCM)
header.setUint16(20, 1, Endian.little);
// 通道数量
header.setUint16(22, channels, Endian.little);
// 采样率
header.setUint32(24, sampleRate, Endian.little);
// 字节率 = 采样率 * 通道数 * 每样本字节数
header.setUint32(28, byteRate, Endian.little);
// 块对齐 = 通道数 * 每样本字节数
header.setUint16(32, blockAlign, Endian.little);
// 每个样本位数
header.setUint16(34, bytesPerSample * 8, Endian.little);
// "data"子块
header.setUint8(36, 0x64); // 'd'
header.setUint8(37, 0x61); // 'a'
header.setUint8(38, 0x74); // 't'
header.setUint8(39, 0x61); // 'a'
// 数据大小
header.setUint32(40, dataLength, Endian.little);
return header.buffer.asUint8List();
}
}

522
apps/client/lib/modules/opus_test/views/opus_test_view.dart

@ -1,522 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/opus_test_controller.dart';
class OpusTestView extends GetView<OpusTestController> {
const OpusTestView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// 获取主题模式
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return DefaultTabController(
length: 2,
child: Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
appBar: AppBar(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
elevation: 0.5,
title: Text(
'OPUS解码',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
centerTitle: true,
bottom: TabBar(
indicatorColor: Colors.blue,
indicatorWeight: 3,
labelColor: isDarkMode ? Colors.white : Colors.black,
unselectedLabelColor: isDarkMode ? Colors.grey : Colors.grey,
tabs: const [
Tab(text: "解码"),
Tab(text: "输出"),
],
),
),
body: TabBarView(
children: [
_buildDecodeTab(isDarkMode),
_buildEncodeTab(isDarkMode),
],
),
),
);
}
// 解码标签页
Widget _buildDecodeTab(bool isDarkMode) {
return ListView(
padding: EdgeInsets.all(16.w),
children: [
// 文件选择部分
_buildSection(
title: '选择文件',
isDarkMode: isDarkMode,
child: Column(
children: [
// 当前选中的文件
Obx(() => controller.selectedFiles.isEmpty
? const SizedBox.shrink()
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.selectedFiles.length,
itemBuilder: (context, index) {
final file = controller.selectedFiles[index];
return ListTile(
title: Text(
file.name,
style: TextStyle(
color:
isDarkMode ? Colors.white70 : Colors.black87,
fontSize: 14.sp,
),
),
subtitle: Text(
'${(file.size / 1024).toStringAsFixed(2)} Kb',
style: TextStyle(
color:
isDarkMode ? Colors.white38 : Colors.black54,
fontSize: 12.sp,
),
),
trailing: IconButton(
icon: Icon(Icons.clear, color: Colors.red),
onPressed: () {
controller.selectedFiles.removeAt(index);
},
),
);
},
)),
// 外部文件列表标题和刷新按钮
Padding(
padding: EdgeInsets.symmetric(vertical: 8.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'外部存储文件',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black,
),
),
IconButton(
icon: Icon(
Icons.refresh,
color: isDarkMode ? Colors.blue[300] : Colors.blue,
),
onPressed: controller.loadExternalFiles,
),
],
),
),
// 外部文件列表
Obx(() => controller.externalFiles.isEmpty
? Center(
child: Padding(
padding: EdgeInsets.all(16.h),
child: Text(
'未找到Opus文件',
style: TextStyle(
color: isDarkMode ? Colors.white54 : Colors.black54,
),
),
),
)
: Container(
height: 200.h,
decoration: BoxDecoration(
border: Border.all(
color: isDarkMode
? Colors.grey[700]!
: Colors.grey[300]!,
),
borderRadius: BorderRadius.circular(8.r),
),
child: ListView.builder(
itemCount: controller.externalFiles.length,
itemBuilder: (context, index) {
final file = controller.externalFiles[index];
return ListTile(
title: Text(
file.name,
style: TextStyle(
color: isDarkMode
? Colors.white70
: Colors.black87,
fontSize: 14.sp,
),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${(file.size / 1024).toStringAsFixed(2)} Kb',
style: TextStyle(
color: isDarkMode
? Colors.white38
: Colors.black54,
fontSize: 12.sp,
),
),
onTap: () {
controller.selectExternalFile(file);
},
onLongPress: () {
// 长按显示操作菜单
controller.showFileActionSheet(context, file);
},
trailing: Icon(
Icons.arrow_forward,
color:
isDarkMode ? Colors.white54 : Colors.black54,
),
);
},
),
)),
],
),
),
SizedBox(height: 16.h),
// 解码参数部分
_buildSection(
title: '解码参数',
isDarkMode: isDarkMode,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Obx(() => Checkbox(
value: controller.hasHeader.value,
onChanged: (value) => controller.toggleHeader(value!),
activeColor: Colors.blue,
)),
Text(
'是否携带协议头',
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black87,
fontSize: 14.sp,
),
),
],
),
// 数据包长度
Row(
children: [
Text(
'数据包长度',
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black87,
fontSize: 14.sp,
),
),
SizedBox(width: 16.w),
Expanded(
child: Obx(() => TextField(
controller: TextEditingController(
text: controller.packetLength.value),
onChanged: (value) =>
controller.setPacketLength(value),
keyboardType: TextInputType.number,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
),
decoration: InputDecoration(
isDense: true,
suffix: Text(
'Byte',
style: TextStyle(
color: isDarkMode
? Colors.white60
: Colors.black54,
fontSize: 12.sp,
),
),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: isDarkMode
? Colors.white30
: Colors.black26,
),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Colors.blue,
),
),
),
)),
),
],
),
SizedBox(height: 20.h),
// 采样率
Row(
children: [
Text(
'采样率',
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black87,
fontSize: 14.sp,
),
),
SizedBox(width: 16.w),
Expanded(
child: Obx(() => DropdownButton<int>(
value: controller.selectedSampleRate.value,
isExpanded: true,
dropdownColor:
isDarkMode ? Colors.grey[800] : Colors.white,
items: controller.sampleRateOptions.map((rate) {
return DropdownMenuItem<int>(
value: rate,
child: Text(
rate.toString(),
style: TextStyle(
color: isDarkMode
? Colors.white
: Colors.black87,
),
),
);
}).toList(),
onChanged: (value) =>
controller.setSampleRate(value!),
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black,
),
)),
),
],
),
SizedBox(height: 20.h),
// 通道数量
Text(
'通道数量',
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black87,
fontSize: 14.sp,
),
),
Row(
children: [
Expanded(
child: Row(
children: [
Obx(() => Radio<bool>(
value: true,
groupValue: controller.isMono.value,
onChanged: (value) =>
controller.toggleChannel(value!),
activeColor: Colors.blue,
)),
Text(
'单通道',
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black87,
fontSize: 14.sp,
),
),
],
),
),
Expanded(
child: Row(
children: [
Obx(() => Radio<bool>(
value: false,
groupValue: controller.isMono.value,
onChanged: (value) =>
controller.toggleChannel(value!),
activeColor: Colors.blue,
)),
Text(
'双通道',
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black87,
fontSize: 14.sp,
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 16.h),
// 状态消息
Obx(() => controller.statusMessage.value.isNotEmpty
? Container(
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w),
margin: EdgeInsets.only(bottom: 16.h),
decoration: BoxDecoration(
color: isDarkMode
? Colors.blue.withOpacity(0.2)
: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(4.r),
border: Border.all(
color: isDarkMode
? Colors.blue.withOpacity(0.3)
: Colors.blue.withOpacity(0.2),
width: 1,
),
),
child: Text(
controller.statusMessage.value,
style: TextStyle(
color: isDarkMode ? Colors.blue[300] : Colors.blue[700],
fontSize: 12.sp,
),
),
)
: const SizedBox.shrink()),
// 播放按钮
Container(
margin: EdgeInsets.symmetric(vertical: 8.h),
child: ElevatedButton.icon(
icon: Obx(() => Icon(
controller.isPlaying.value ? Icons.stop : Icons.play_arrow,
color: Colors.white,
)),
label: Obx(() => Text(
controller.isPlaying.value ? '停止播放' : '播放音频',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
padding: EdgeInsets.symmetric(vertical: 16.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
onPressed: controller.isDecoding.value
? null
: () {
controller.playAudio();
},
),
),
// 解码按钮
Container(
margin: EdgeInsets.symmetric(vertical: 8.h),
child: Obx(() => ElevatedButton(
onPressed: controller.selectedFiles.isEmpty ||
controller.isDecoding.value
? null
: () {
controller.pickFiles();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
padding: EdgeInsets.symmetric(vertical: 16.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
child: Text(
'开始解码',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
),
)),
),
],
);
}
// 编码标签页(暂未实现)
Widget _buildEncodeTab(bool isDarkMode) {
return Center(
child: Text(
'编码功能尚未实现',
style: TextStyle(
color: isDarkMode ? Colors.white54 : Colors.black54,
fontSize: 16.sp,
),
),
);
}
// 构建带标题的区段
Widget _buildSection({
required String title,
required Widget child,
required bool isDarkMode,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(width: 8.w),
Expanded(
child: Container(
height: 1,
color: isDarkMode ? Colors.white24 : Colors.black12,
),
),
if (title == '选择文件')
IconButton(
icon: Icon(
Icons.add_circle_outline,
color: isDarkMode ? Colors.blue[300] : Colors.blue,
),
onPressed: () {
controller.pickFiles();
},
),
],
),
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white,
borderRadius: BorderRadius.circular(8.r),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.03),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: child,
),
],
);
}
}

495
apps/client/lib/modules/ota_upgrade/controllers/bes_ota_upgrade_controller.dart

@ -1,495 +0,0 @@
import 'dart:async';
import 'package:bes_ota_manager/bes_ota_manager.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../../core/utils/logger.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
import '../../../data/services/bes_bluetooth_service.dart';
import 'bes_ota_download_service.dart';
/// OTA 升级阶段(驱动页面 UI)。
class OtaPhase {
static const idle = 0; // 待升级
static const downloading = 1; // 下载固件中
static const upgrading = 2; // 蓝牙推送中
static const success = 3; // 升级成功
static const failed = 4; // 升级失败
}
/// 恒玄(BES)耳机固件 OTA 升级控制器。
///
/// 与杰理那条链路([OtaCoreService] / `ota_upgrade_controller.dart`)完全独立:
/// 不碰 BleManager / Jielihome,只依赖 [BesBluetoothService] 与 `bes_ota_manager` 插件。
/// 本项目只有恒玄耳机,路由 `Routes.OTA_UPGRADE` 已指向这里。
///
/// 流程:读产品配置 → 断开 app 层 SPP → 下载固件 → 插件自管连接推送 → 设备重启。
class BesOtaUpgradeController extends GetxController {
static const _tag = 'BesOtaUpgradeController';
/// 看门狗超时:升级阶段超过该时长无任何进度或状态推进,即判定卡死失败。
/// 兜底原生侧「既不推进也不上报 onError」导致 UI 永久停在升级中的情况。
static const Duration _watchdogTimeout = Duration(seconds: 90);
/// 固件下载地址:来自后端 `user_getappconfig` 下发的产品配置,按设备名匹配。
String _downloadUrl = '';
/// 页面已销毁标志:onClose 置位,用于让正在进行的下载/升级异步链提前退出,
/// 避免在已销毁的控制器上发起 startOta(僵尸会话)。
bool _disposed = false;
/// 当前下载的取消令牌:cancel()/onClose 时取消,立即中止下载、不再续跑。
CancelToken? _downloadCancelToken;
/// 升级看门狗定时器:每次收到进度/状态事件即重置;超时未重置则判失败。
Timer? _watchdog;
final BesBluetoothService _bes = BesBluetoothService.to;
final BesOtaManager _besOta = BesOtaManager();
final BesOtaDownloadService _downloadService = BesOtaDownloadService();
// ---- 响应式状态 ----
final RxString currentVersion = ''.obs;
final RxString latestVersion = ''.obs;
final RxString updateDescription = ''.obs;
final RxBool isUpdateAvailable = false.obs;
final RxBool isConnected = true.obs;
/// 设备名快照:开始升级时会主动断开 app 层连接,[BesBluetoothService] 的字段被
/// 清空,页面展示用控制器自己的快照,不跟着变空。
final RxString deviceName = ''.obs;
/// 设备地址快照,服务字段被清空后(如失败重试时)回退使用。
String _address = '';
/// 是否为本次 OTA 主动断开过 app 层连接。主动断开不会自动回连,
/// 退出页面时据此决定是否恢复连接(没点过「开始升级」则无需处理)。
bool _appConnectionReleased = false;
final RxInt phase = OtaPhase.idle.obs;
final RxDouble downloadProgress = 0.0.obs; // 0~1
final RxDouble otaProgress = 0.0.obs; // 0~1
final RxString detailedStatus = ''.obs;
StreamSubscription<BesOtaEvent>? _otaSub;
Worker? _versionWorker;
Worker? _statusWorker;
Worker? _deviceWorker;
Worker? _nameWorker;
bool get isBusy =>
phase.value == OtaPhase.downloading || phase.value == OtaPhase.upgrading;
@override
void onInit() {
super.onInit();
isConnected.value = _bes.isConnected;
currentVersion.value = _bes.deviceVersion.value;
deviceName.value = _bes.connectedDeviceName.value;
_address = _bes.connectedDeviceId.value;
_loadProductConfig();
// 设备版本回包(`AA 06` → `BB 86`)后同步刷新;开始升级时主动断开会把
// deviceVersion 清空,空值忽略,保留页面上已知的版本显示。
_versionWorker = ever(_bes.deviceVersion, (String v) {
if (v.isEmpty) return;
currentVersion.value = v;
_refreshUpdateAvailable();
});
// 跟踪 app 层连接状态(仅用于展示与按钮可用性)。
// 注意:OTA 连接由插件自管、掉线会经 onError 上报;开始升级时 app 层连接
// 是我们主动断开的,不能拿它的断开去中止 OTA。
_statusWorker = ever(_bes.deviceStatus, (int s) {
isConnected.value = s == 2;
});
// (重)连接后重新捕获设备地址,失败重试时要用。
_deviceWorker = ever(_bes.connectedDeviceId, (String id) {
if (id.isEmpty) return;
_address = id;
});
// 设备名就绪/变化时刷新快照并重载产品配置。注意:重连时服务里 id 先于
// name 赋值且 worker 同步触发,挂在 id 上拿不到新名字;页面初始化恰逢
// 重连中名字为空时,也靠这里在名字就绪后补加载,否则「最新版本」会一直为空。
_nameWorker = ever(_bes.connectedDeviceName, (String n) {
if (n.isEmpty) return;
deviceName.value = n;
_loadProductConfig();
});
_otaSub = _besOta.events.listen(
_handleOtaEvent,
// 事件通道本身异常(原生 sink 出错/通道断开)也要落到失败终态,避免无声卡死。
onError: (Object err) {
if (phase.value == OtaPhase.success) return;
_onUpgradeError(null, err.toString());
},
);
}
@override
void onClose() {
_disposed = true;
_downloadCancelToken?.cancel('page closed'); // 中止可能仍在跑的下载
_cancelWatchdog();
_setKeepScreenOn(false); // 退出页面兜底关闭常亮
_otaSub?.cancel();
_versionWorker?.dispose();
_statusWorker?.dispose();
_deviceWorker?.dispose();
_nameWorker?.dispose();
// 主动断开不会自动回连:若本次为 OTA 断开过 app 层连接,退出页面时恢复。
// 升级成功时设备正在重启,延时几秒再连。
final needReconnect = _appConnectionReleased && _address.isNotEmpty;
final addr = _address;
final name = deviceName.value;
// SDK 自己 OTA 成功后的内部重连等了 15s(MSG_OTA_OVER_RECONNECT),设备重启到
// 蓝牙就绪需要数秒;即使这次连不上,耳机开机也会主动回连手机兜底。
final delay = phase.value == OtaPhase.success
? const Duration(seconds: 5) // 设备正在重启
: const Duration(seconds: 2); // 等耳机拆完 OTA 链路再回连(与开始升级方向一致)
// 先停掉 OTA 会话释放插件占用的 SPP 连接,再回连 app 层,避免两条连接打架。
_besOta.stopOta().whenComplete(() {
_besOta.dispose();
if (!needReconnect) return;
Future.delayed(delay, () {
// 期间可能已(自动/手动)连上,避免重复发起。
if (_bes.deviceStatus.value != 2) {
_bes.connectToDevice(addr, name);
}
});
});
super.onClose();
}
/// 从后端产品配置取最新固件版本与下载地址(按设备名匹配产品)。
void _loadProductConfig() {
final product = _findProduct(deviceName.value);
if (product == null) {
Logger.w(_tag, '未找到设备对应的产品配置: ${deviceName.value}');
latestVersion.value = '';
updateDescription.value = '';
_downloadUrl = '';
} else {
latestVersion.value = product.version?.split('+').first.trim() ?? '';
updateDescription.value = product.updatedescription ?? '';
_downloadUrl = product.updatepackageaddress ?? '';
// 「拉到的版本不对」这类问题以前只能靠猜:失败有日志、成功没有,
// 分不清是没匹配上产品、下发的值就是旧的、还是设备当前版本读错了。
Logger.i(
_tag,
'产品配置: name=${product.devicename} pid=${product.id} '
'服务端最新=${product.version} → 解析后=${latestVersion.value} '
'设备当前=${currentVersion.value} '
'产品表共${AppConfig.config.products.length}个');
}
_refreshUpdateAvailable();
}
/// 后台是否允许「版本不一致即可升级」。取不到产品配置时按 false(严格)。
bool get _allowDowngrade =>
_findProduct(deviceName.value)?.updateonmismatch ?? false;
/// 按设备名找产品。
///
/// ⚠️ 不要用 [AppConfig.getproduct]——它内部 `firstWhere` 没有 orElse,
/// 查不到直接抛 StateError,而调用点写的都是 `if (product == null)`,
/// 那个 null 判断永远等不到,异常会穿出去、在固件页表现成「加载失败」。
///
/// 顺序:设备名互相包含 → 唯一的经典蓝牙耳机产品 → null。
DBProduct? _findProduct(String name) {
if (!AppConfig.isInitialized()) return null;
final products = AppConfig.config.products;
if (products.isEmpty) return null;
final n = name.trim().toUpperCase();
if (n.isNotEmpty && n != 'UNKNOWNEQUIPMENT') {
for (final p in products) {
final pn = p.devicename.trim().toUpperCase();
if (pn.isEmpty) continue;
if (pn.contains(n) || n.contains(pn)) return p;
}
}
// 本项目只有恒玄(经典蓝牙耳机)一种设备,名字对不上时退化到它是安全的
final headsets = products.where((p) => p.devicetype == 1).toList();
if (headsets.length == 1) return headsets.first;
return null;
}
void _refreshUpdateAvailable() {
final cur = currentVersion.value.trim();
final latest = latestVersion.value.trim();
// 没有产品配置(无最新版本)不可升级
if (latest.isEmpty) {
isUpdateAvailable.value = false;
return;
}
// 读不到当前版本时允许升级(未知 → 允许)
if (cur.isEmpty) {
isUpdateAvailable.value = true;
return;
}
// 判定口径由**后台产品配置**决定,不看包类型:
// updateonmismatch=true → 版本不一致就允许升(开发/测试,可刷回旧固件)
// updateonmismatch=false → 只有服务端版本更高才允许(正式,默认)
// ⚠️ 之前用 kDebugMode 分界是错的:测试同事拿到的往往是 release 包,
// 在开发环境照样点不动升级按钮。开关放后台才能按产品、按环境自由控制。
// ⚠️ 字段可空(老服务端不下发),null 一律按 false 走严格判定——
// 判宽的后果是线上用户被降级,判严只是开发时少一个便利。
isUpdateAvailable.value = _allowDowngrade
? latest != cur
: _isNewer(latest, cur);
}
/// 入口:开始升级(下载 → 交接连接 → OTA 推送)。
Future<void> startUpdate() async {
if (isBusy) return;
if (!_bes.isConnected) {
_snack('otaError'.tr, 'otaNeedConnect'.tr);
return;
}
// 关键:在断开现有连接前先捕获设备标识(disconnect 会清空);
// 服务字段已为空时(失败后重试)回退到控制器快照。
final serviceId = _bes.connectedDeviceId.value;
final address = serviceId.isNotEmpty ? serviceId : _address;
final serviceName = _bes.connectedDeviceName.value;
final name = serviceName.isNotEmpty ? serviceName : deviceName.value;
if (address.isEmpty) {
_snack('otaError'.tr, 'otaNeedConnect'.tr);
return;
}
_address = address;
// 产品配置缺失(AppConfig 晚于页面初始化等)时兜底重取一次。
if (_downloadUrl.isEmpty) {
_loadProductConfig();
if (_downloadUrl.isEmpty) {
_snack('otaError'.tr, 'otaVersionInfoFailed'.tr);
return;
}
}
final cancelToken = CancelToken();
_downloadCancelToken = cancelToken;
try {
// 升级期间保持屏幕常亮(成功/失败/退出页面时关闭)。
_setKeepScreenOn(true);
phase.value = OtaPhase.downloading;
downloadProgress.value = 0;
otaProgress.value = 0;
detailedStatus.value = 'otaPreparing'.tr;
// 1) 释放 app 现有 SPP 连接,让插件自管 OTA 连接。
await _bes.disconnectDevice();
_appConnectionReleased = true;
// disconnect 返回只代表本端 socket 已关,耳机侧链路拆除还要在空中传播;
// 固件已缓存时下载不耗时、衔接近乎瞬时,立刻连 OTA 可能被拒
//(多数固件同时只接受一条 SPP 链路),留 2s 缓冲等耳机拆完旧链路。
// 曾出现过快速衔接导致 OTA 连接失败的案例,SDK 内部还有 3 次快速重连兜底。
await Future.delayed(const Duration(seconds: 2));
if (_disposed) return; // 期间已退出页面,不再继续
// 2) 初始化原生 OTA 环境。
await _besOta.init();
// 3) 下载固件。
final filePath = await _downloadService.downloadFirmware(
downloadUrl: _downloadUrl,
cancelToken: cancelToken,
onProgress: (p) => downloadProgress.value = p,
onStatusUpdate: (s) => detailedStatus.value = s,
);
// 已退出/已取消则不再发起原生 OTA,避免在已销毁控制器上建立僵尸会话。
if (_disposed || cancelToken.isCancelled) return;
// 4) 启动 SPP OTA 2.0。
phase.value = OtaPhase.upgrading;
detailedStatus.value = 'otaConnectingDevice'.tr;
// 看门狗从这里起覆盖「连接+握手+推送」全程:原生握手会持续发 onStatus、
// 传输会持续发 onProgress,任一推进即重置;长时间静默(如原生卡住且不报错)兜底失败。
// 下载阶段的卡死另由 Dio 超时与 CancelToken 处理,不纳入看门狗。
_armWatchdog();
await _besOta.startOta(
address: address,
name: name,
filePath: filePath,
protocol: 'SPP',
userFlag: 1,
upgradeType: 1,
user: 1,
);
} catch (e, st) {
// 用户取消下载 / 页面已退出:静默收尾(控制器可能已销毁)。
if (_disposed || (e is DioException && CancelToken.isCancel(e))) {
return;
}
Logger.e(_tag, '升级流程异常', e, st);
_onUpgradeError(null, e.toString()); // 统一走失败终态(含去重/看门狗关闭)
}
}
/// 取消升级。
void cancel() {
_downloadCancelToken?.cancel('user cancelled'); // 中止下载,阻断后续 startOta
_cancelWatchdog();
_besOta.stopOta();
_setKeepScreenOn(false);
phase.value = OtaPhase.idle;
detailedStatus.value = '';
downloadProgress.value = 0;
otaProgress.value = 0;
}
// ---- 看门狗:覆盖升级(连接/握手/推送)阶段,超时未推进则判失败 ----
void _armWatchdog() {
if (_disposed) return;
_watchdog?.cancel();
_watchdog = Timer(_watchdogTimeout, _onWatchdogTimeout);
}
void _cancelWatchdog() {
_watchdog?.cancel();
_watchdog = null;
}
void _onWatchdogTimeout() {
if (_disposed) return;
if (phase.value == OtaPhase.upgrading) {
Logger.w(_tag, 'OTA 看门狗超时:${_watchdogTimeout.inSeconds}s 无进度推进');
_onUpgradeError(null, 'otaTimeout'.tr);
}
}
/// 屏幕常亮开关。失败不致命,吞掉异常。
void _setKeepScreenOn(bool on) {
try {
on ? WakelockPlus.enable() : WakelockPlus.disable();
} catch (e) {
Logger.w(_tag, '设置屏幕常亮失败: $e');
}
}
void _handleOtaEvent(BesOtaEvent e) {
// 进入终态(成功/失败)即锁定:stopOta 清理、设备重启断链、看门狗超时后迟到的
// 进度/Canceled/Error 一律忽略,避免成功被覆盖成失败、或失败被迟到进度翻回升级中。
// 新会话由 startUpdate 重置 phase。
if (phase.value == OtaPhase.success || phase.value == OtaPhase.failed) {
return;
}
// 任何进度/状态推进都重置看门狗(成功/失败在各自处理里关闭看门狗)。
if (e.event == 'onProgress' || e.event == 'onStatus') {
_armWatchdog();
}
switch (e.event) {
case 'onProgress':
phase.value = OtaPhase.upgrading;
_updateProgress(e.progress ?? 0);
break;
case 'onStatus':
// SDK 的 OTA_SEND_DATA_PROGRESS 状态消息带原始浮点百分比(如 99.94765…),
// 当作进度更新并入单一写入口;其余状态文本只记日志不上屏——
// 曾直接展示,但与进度文案高频交替会闪烁出看不清的内容。
final numeric = double.tryParse(e.message ?? '');
if (numeric != null && numeric > 0) {
phase.value = OtaPhase.upgrading;
_updateProgress((numeric / 100).clamp(0.0, 1.0));
}
break;
case 'onSuccess':
_onUpgradeSuccess();
break;
case 'onError':
_onUpgradeError(e.code, e.message);
break;
}
}
/// 进度的单一写入口:onProgress(Android 为整数百分比)与数字型 onStatus
/// 消息(浮点百分比)都汇到这里,只在单调递增时刷新,避免两个来源
/// 高频交替导致数字来回跳动。
void _updateProgress(double p) {
if (p <= otaProgress.value) return;
otaProgress.value = p;
detailedStatus.value = 'otaUpgrading'.trParams({
'percent': (p * 100).toStringAsFixed(2),
});
}
void _onUpgradeSuccess() {
_cancelWatchdog();
_setKeepScreenOn(false);
phase.value = OtaPhase.success;
otaProgress.value = 1.0;
detailedStatus.value = 'otaSuccessReboot'.tr;
currentVersion.value = latestVersion.value;
isUpdateAvailable.value = false;
_besOta.stopOta();
}
void _onUpgradeError(int? code, String? message) {
if (phase.value == OtaPhase.failed) return; // 已是失败终态,避免重复弹窗
_cancelWatchdog();
_setKeepScreenOn(false);
phase.value = OtaPhase.failed;
// 进入失败终态即释放原生 OTA 会话与 SPP 链路(与 _onUpgradeSuccess/cancel 对齐)。
// 否则看门狗超时/连接失败后 native 会话仍在跑,占用唯一 SPP,且因终态已锁,
// 迟到的 onSuccess 会被吞——会卡死在「失败」直到用户退页面才由 onClose 清理。
_besOta.stopOta();
detailedStatus.value = 'otaFailed'.tr;
_snack(
'otaFailed'.tr,
message?.isNotEmpty == true
? message!
: 'otaFailedWithCode'.trParams({'code': '${code ?? -1}'}),
);
}
void _snack(String title, String msg) {
Get.snackbar(
title,
msg,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red[400],
colorText: Colors.white,
margin: const EdgeInsets.all(12),
duration: const Duration(seconds: 3),
);
}
/// [a] 是否比 [b] 新。
///
/// 逐段取前导数字比较,与服务端 `comm.CompareVersion` 同口径——
/// `1.0.0-beta` / `1.2.3+4` 这类版本串不会抛异常(用 int.parse 会抛,
/// 被外层 catch 吞掉后表现为「明明有新版本却说已是最新」)。
bool _isNewer(String a, String b) {
final pa = _splitVersion(a);
final pb = _splitVersion(b);
if (pa.isEmpty || pb.isEmpty) return a.trim() != b.trim();
final len = pa.length > pb.length ? pa.length : pb.length;
for (var i = 0; i < len; i++) {
final x = i < pa.length ? pa[i] : 0;
final y = i < pb.length ? pb[i] : 0;
if (x != y) return x > y;
}
return false;
}
/// 把版本串按 `.` 切开,每段只取前导数字(`3+4` → 3,`0-beta` → 0)。
List<int> _splitVersion(String v) {
final s = v.trim();
if (s.isEmpty) return const [];
return s.split('.').map((seg) {
final m = RegExp(r'^\d+').firstMatch(seg.trim());
return m == null ? 0 : int.parse(m.group(0)!);
}).toList();
}
}

659
apps/client/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart

@ -1,659 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:logger/web.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/bes_bluetooth_service.dart';
import '../../../data/services/bluetooth_manager.dart'; // 添加蓝牙管理器导入
import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
enum OtaCoreErrorType {
deviceInfoUnavailable,
productInfoUnavailable,
firmwareVersionFormatInvalid,
}
class OtaCoreException implements Exception {
final OtaCoreErrorType type;
final Map<String, String> params;
final String debugMessage;
OtaCoreException({
required this.type,
this.params = const {},
this.debugMessage = '',
});
@override
String toString() => debugMessage;
}
/// OTA核心服务
/// 整合设备检查、版本管理和状态管理功能
class OtaCoreService {
final BleManager _bleManager = Get.find<BleManager>();
final BluetoothManager _bluetoothManager =
Get.find<BluetoothManager>(); // 添加蓝牙管理器引用
final int maxRetryAttempts = 3;
int currentRetryCount = 0;
// ==================== 状态常量定义 ====================
static const int NOT_STARTED = -1; // 未开始
static const int VALIDATION_FILE = 0; // 验证文件
static const int ONUPGRADE = 1; // 升级中
static const int START_UPGRADING = 2; // 开始升级
static const int UPGRADE_SUCCESS = 3; // 升级成功
static const int UPGRADE_FAILED = 4; // 升级失败
static const int CONNECTION_DISCONNECTION = 5; // 连接断开
static const int CONNECTION_SUCCESS = 6; // 连接成功
static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件
/// 获取状态描述信息
/// [type] 状态类型
/// 返回对应的状态描述文本
static String getStatusMessage(int type) {
switch (type) {
case NOT_STARTED:
return "未开始".tr;
case VALIDATION_FILE:
return "验证文件中".tr;
case ONUPGRADE:
return "升级中".tr;
case START_UPGRADING:
return "开始升级".tr;
case UPGRADE_SUCCESS:
return "升级成功".tr;
case UPGRADE_FAILED:
return "升级失败".tr;
case CONNECTION_DISCONNECTION:
return "连接断开".tr;
case CONNECTION_SUCCESS:
return "连接成功".tr;
case DOWNLOADING_UP_FILES:
return "下载升级文件".tr;
default:
return "未知状态".tr;
}
}
// ==================== 设备检查功能 ====================
/// 检查BLE设备连接状态
/// 返回true表示设备已连接,可以进行升级
/// 返回false表示设备未连接,不能进行升级
bool checkBleConnectionStatus() {
try {
// 检查BLE管理器的连接状态
if (!_bleManager.isConnected) {
//_showBleDisconnectedDialog();
return false;
}
// 检查设备信息是否可用
final deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo == null) {
_showDeviceInfoUnavailableDialog();
return false;
}
return true;
} catch (e) {
_showBleCheckFailedSnackbar('otaBleCheckErrorWithError'.trParams({'error': e.toString()}));
return false;
}
}
/// 显示BLE设备未连接对话框
void _showBleDisconnectedDialog() {
Get.dialog(
AlertDialog(
title: Text(
'otaBleNotConnected'.tr,
style: TextStyle(
color: Colors.red[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
'otaBleNeedConnection'.tr,
style: TextStyle(
color: Colors.red[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.red[700],
),
child: Text('okConfirm'.tr),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.red.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.red[50],
),
);
}
/// 显示设备信息不可用对话框
void _showDeviceInfoUnavailableDialog() {
Get.dialog(
AlertDialog(
title: Text(
'otaDeviceInfoUnavailableTitle'.tr,
style: TextStyle(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
'otaCannotGetDeviceInfo'.tr,
style: TextStyle(
color: Colors.orange[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.orange[700],
),
child: Text('okConfirm'.tr),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.orange[50],
),
);
}
/// 显示BLE连接检查失败提示
/// [message] 错误信息
void _showBleCheckFailedSnackbar(String message) {
Get.snackbar(
'otaBleCheckFailed'.tr,
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[600],
colorText: Colors.white,
icon: const Icon(
Icons.bluetooth_disabled,
color: Colors.white,
),
);
}
/// 检查设备电量是否满足升级条件
/// 返回true表示电量充足,可以进行升级
/// 返回false表示电量不足,不能进行升级
bool checkBatteryLevel() {
try {
final deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo == null) {
_showBatteryCheckFailedSnackbar('otaCannotGetDeviceInfoCheckConnection'.tr);
return false;
}
// 获取设备类型以确定检查策略
String deviceName = getDeviceName();
final DBProduct? product = findProduct(deviceName);
if (product == null) {
_showBatteryCheckFailedSnackbar('otaCannotGetProductInfoWithName'
.trParams({'deviceName': deviceName}));
return false;
}
final leftBattery = deviceInfo.leftEarBattery;
final rightBattery = deviceInfo.rightEarBattery;
// 根据设备类型进行不同的电量检查策略
if (product.devicetype == DeviceType.bleBluetoothSingleHeadset.value ||
product.devicetype == DeviceType.smartEye.value ||
product.devicetype == DeviceType.smartVoiceRecorder.value) {
// 单端耳机:只需要检查有效的那一端电量
return _checkSingleHeadsetBattery(leftBattery, rightBattery);
} else if (product.devicetype ==
DeviceType.bleBluetoothDoubleHeadset.value) {
// 双端耳机:需要检查左右两端电量
return _checkDoubleHeadsetBattery(leftBattery, rightBattery);
} else {
// 其他设备类型,使用原有逻辑(双端检查)
return _checkDoubleHeadsetBattery(leftBattery, rightBattery);
}
} catch (e) {
_showBatteryCheckFailedSnackbar('otaBleCheckErrorWithError'.trParams({'error': e.toString()}));
return false;
}
}
/// 检查单端耳机电量
/// [leftBattery] 左耳电量
/// [rightBattery] 右耳电量
/// 返回true表示电量充足
bool _checkSingleHeadsetBattery(int leftBattery, int rightBattery) {
// 单端耳机逻辑:优先检查左耳,如果左耳无效(<=0或127)则检查右耳
int activeBattery;
String activeEar;
if (leftBattery > 0 && leftBattery != 127) {
// 左耳有效,使用左耳电量
activeBattery = leftBattery;
activeEar = 'leftEar'.tr;
} else if (rightBattery > 0 && rightBattery != 127) {
// 左耳无效,使用右耳电量
activeBattery = rightBattery;
activeEar = 'rightEar'.tr;
} else {
// 两边都无效
_showBatteryCheckFailedSnackbar('otaCannotGetBatteryInfo'.tr);
return false;
}
// 检查有效端的电量是否大于20%
if (activeBattery <= 20) {
_showSingleHeadsetLowBatteryDialog(activeEar, activeBattery);
return false;
}
return true;
}
/// 检查双端耳机电量
/// [leftBattery] 左耳电量
/// [rightBattery] 右耳电量
/// 返回true表示电量充足
bool _checkDoubleHeadsetBattery(int leftBattery, int rightBattery) {
// 双端耳机逻辑:需要检查左右两端电量是否都大于20%
// 检查电量数据有效性
bool leftValid = leftBattery > 0 && leftBattery != 127;
bool rightValid = rightBattery > 0 && rightBattery != 127;
if (!leftValid || !rightValid) {
_showBatteryCheckFailedSnackbar('otaCannotGetBatteryInfo'.tr);
return false;
}
// 如果只有一端有效,按单端处理
// if (!leftValid || !rightValid) {
// String activeEar = leftValid ? '左耳' : '右耳';
// int activeBattery = leftValid ? leftBattery : rightBattery;
// if (activeBattery <= 20) {
// _showSingleHeadsetLowBatteryDialog(activeEar, activeBattery);
// return false;
// }
// return true;
// }
// 两端都有效,检查是否都大于20%
if (leftBattery <= 20 || rightBattery <= 20) {
_showLowBatteryDialog(leftBattery, rightBattery);
return false;
}
return true;
}
/// 显示单端耳机电量不足对话框
/// [earSide] 耳机端(左耳/右耳)
/// [batteryLevel] 电量百分比
void _showSingleHeadsetLowBatteryDialog(String earSide, int batteryLevel) {
String message = 'otaSingleBatteryLow'.trParams({
'ear': earSide,
'battery': batteryLevel.toString(),
});
Get.dialog(
AlertDialog(
title: Text(
'otaLowBattery'.tr,
style: TextStyle(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
message,
style: TextStyle(
color: Colors.orange[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.orange[700],
),
child: Text('okConfirm'.tr),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.orange[50],
),
);
}
/// 显示电量不足对话框
/// [leftBattery] 左耳电量
/// [rightBattery] 右耳电量
void _showLowBatteryDialog(int leftBattery, int rightBattery) {
String message = 'otaDoubleBatteryLow'.trParams({
'left': leftBattery.toString(),
'right': rightBattery.toString(),
});
Get.dialog(
AlertDialog(
title: Text(
'otaLowBattery'.tr,
style: TextStyle(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
message,
style: TextStyle(
color: Colors.orange[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.orange[700],
),
child: Text('okConfirm'.tr),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.orange[50],
),
);
}
/// 显示电量检查失败提示
/// [message] 错误信息
void _showBatteryCheckFailedSnackbar(String message) {
Get.snackbar(
'otaBatteryCheckFailed'.tr,
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: const Icon(
Icons.warning_amber,
color: Colors.white,
),
);
}
// ==================== 版本管理功能 ====================
/// 按设备名安全地查产品配置。
///
/// 匹配规则比 `AppConfig.getproduct()` 宽:后者只做「产品名包含设备名」这一个
/// 方向,恒玄耳机的广播名与产品表里的名字常常互为子串的另一头,单向匹配会漏。
/// (getproduct 本身已经不会抛了,2026-09-03 起查不到返回 null。)
///
/// 顺序:设备名互相包含 → 唯一的经典蓝牙耳机产品 → null。
/// 老代码里的兜底名 "DeapSound" 是上一代品牌,早已不在产品表里,去掉了。
DBProduct? findProduct(String deviceName) {
if (!AppConfig.isInitialized()) return null;
final products = AppConfig.config.products;
if (products.isEmpty) return null;
final n = deviceName.trim().toUpperCase();
if (n.isNotEmpty && n != 'UNKNOWNEQUIPMENT') {
for (final p in products) {
final pn = p.devicename.trim().toUpperCase();
if (pn.isEmpty) continue;
if (pn.contains(n) || n.contains(pn)) return p;
}
}
// 本项目只有恒玄(经典蓝牙耳机)一种设备,名字对不上时退化到它是安全的
final headsets = products.where((p) => p.devicetype == 1).toList();
if (headsets.length == 1) return headsets.first;
return null;
}
/// 动态获取当前连接的设备名称
/// 参考home_controller中的getDeviceName方法实现
String getDeviceName() {
try {
// 优先从经典蓝牙获取设备名称
final currentDevice = _bluetoothManager.currentDevice;
if (currentDevice?.name != null && currentDevice!.name.isNotEmpty) {
return currentDevice.name;
}
// 其次从BLE设备信息获取
final deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo?.name != null && deviceInfo!.name.isNotEmpty) {
return deviceInfo.name;
}
// 恒玄耳机不在杰理 RCSP 链路上,上面两个来源都拿不到它。
// 少了这一支,恒玄用户的设备名恒为 unknownEquipment,
// 后面按名字查产品必然落空 → 固件页取不到服务端版本。
if (Get.isRegistered<BesBluetoothService>()) {
final besName = BesBluetoothService.to.connectedDeviceName.value;
if (besName.isNotEmpty) return besName;
}
// 最后从BLE管理器的最后连接设备名称获取
// if (_bleManager.currentDeviceName.isNotEmpty) {
// return _bleManager.currentDeviceName;
// }
// 如果都获取不到,返回默认值
return "unknownEquipment";
} catch (e) {
Logger().e("获取设备名称失败: $e");
return "unknownEquipment";
}
}
/// 获取当前设备版本信息
/// 返回包含当前版本、产品ID和设备信息的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getCurrentVersionInfo() async {
DeviceInfo? deviceInfo = _bleManager.deviceInfo.value;
// 重试机制获取设备信息
while (currentRetryCount < maxRetryAttempts) {
deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo != null &&
deviceInfo.firmwareVersion.isNotEmpty &&
deviceInfo.firmwareProductId.isNotEmpty) {
break;
}
await _bleManager.fetchDeviceInfo();
await Future.delayed(const Duration(seconds: 5));
currentRetryCount++;
}
if (deviceInfo == null ||
deviceInfo.firmwareVersion.isEmpty ||
deviceInfo.firmwareProductId.isEmpty) {
throw OtaCoreException(
type: OtaCoreErrorType.deviceInfoUnavailable,
debugMessage: 'deviceInfoUnavailable', //获取设备信息失败,请检查连接
);
}
String deviceName = getDeviceName();
// 获取产品信息以确定设备类型
final DBProduct? product = findProduct(deviceName);
if (product == null) {
throw OtaCoreException(
type: OtaCoreErrorType.productInfoUnavailable,
params: {'deviceName': deviceName},
debugMessage: 'productInfoUnavailable: $deviceName', //根据设备名称未找到产品信息
);
}
// 根据设备类型进行不同的版本校验
String currentVersion;
if (product.devicetype == DeviceType.bleBluetoothSingleHeadset.value) {
// 单端耳机:固件版本只有三位数(如:1.2.3)
final firmwareParts = deviceInfo.firmwareVersion.split('.');
if (firmwareParts.length < 3) {
throw OtaCoreException(
type: OtaCoreErrorType.firmwareVersionFormatInvalid,
params: {'firmwareVersion': deviceInfo.firmwareVersion},
debugMessage:
'single firmwareVersion format invalid: ${deviceInfo.firmwareVersion}', //单端耳机固件版本格式无效
);
}
currentVersion = firmwareParts.sublist(0, 3).join('.');
Logger().d("单端耳机版本: $currentVersion");
} else if (product.devicetype ==
DeviceType.bleBluetoothDoubleHeadset.value) {
// 双端耳机:固件版本有六位数(如:1.2.3.1.2.3)
final firmwareParts = deviceInfo.firmwareVersion.split('.');
if (firmwareParts.length < 6) {
throw OtaCoreException(
type: OtaCoreErrorType.firmwareVersionFormatInvalid,
params: {'firmwareVersion': deviceInfo.firmwareVersion},
debugMessage:
'double firmwareVersion length invalid: ${deviceInfo.firmwareVersion}', //双端耳机固件版本格式无效
);
}
final firstPart = firmwareParts.sublist(0, 3).join('.');
final secondPart = firmwareParts.sublist(3, 6).join('.');
Logger().d("双端耳机版本 - firstPart: $firstPart, secondPart: $secondPart");
if (firstPart != secondPart) {
throw OtaCoreException(
type: OtaCoreErrorType.firmwareVersionFormatInvalid,
params: {'firmwareVersion': deviceInfo.firmwareVersion},
debugMessage:
'double firmwareVersion mismatch: ${deviceInfo.firmwareVersion}', //双端耳机固件版本不一致,前三位和后三位不一样
);
}
currentVersion = firstPart;
} else {
// 其他设备类型,使用原有逻辑
final firmwareParts = deviceInfo.firmwareVersion.split('.');
if (firmwareParts.length >= 3) {
currentVersion = firmwareParts.sublist(0, 3).join('.');
} else {
currentVersion = deviceInfo.firmwareVersion;
}
Logger().d("其他设备类型版本: $currentVersion");
}
return {
'currentVersion': currentVersion,
'productId': deviceInfo.firmwareProductId,
'deviceInfo': deviceInfo,
};
}
/// 获取最新版本信息
/// 返回包含最新版本、更新描述和更新包地址的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getLatestVersionInfo() async {
// 动态获取当前连接的设备名称
String deviceName = getDeviceName();
final DBProduct? product = findProduct(deviceName);
if (product == null) {
throw "无法获取产品信息,设备名称: $deviceName";
}
// ⚠️ 强更以**后台 product.isforceupdate 字段**为准。
// 原来写的是 `product.version?.contains("+")`——拿版本号里有没有 "+"
// 当强更开关,跟后台配置完全脱节(后台勾了拦不住、版本号带 + 的全被强更)。
// 这和 App 版本更新那边踩过的土规则是同一个毛病,别改回去。
return {
'latestVersion': product.version?.split('+').first ?? "",
'updateDescription': product.updatedescription ?? "",
'updatePackageAddress': product.updatepackageaddress ?? "",
'isForceUpdate': product.isforceupdate ?? false,
};
}
/// 把版本串按 `.` 切开,每段只取前导数字(`3+4` → 3,`0-beta` → 0)。
/// 与服务端 comm.CompareVersion 的口径一致。
static List<int> _splitVersion(String v) {
final s = v.trim();
if (s.isEmpty) return const [];
return s.split('.').map((seg) {
final m = RegExp(r'^\d+').firstMatch(seg.trim());
return m == null ? 0 : int.parse(m.group(0)!);
}).toList();
}
/// 比较版本号,判断是否有新版本可用
/// [currentVersion] 当前版本号
/// [latestVersion] 最新版本号
/// 返回true表示有新版本可用(只支持升级)
bool isNewVersionAvailable(String currentVersion, String latestVersion) {
try {
// ⚠️ 逐段取**前导数字**,不能用 int.parse:固件版本出现过
// `1.0.0-beta` / `1.2.3+4` 这类后缀,int.parse 直接抛,
// 异常被下面的 catch 吞掉后返回 false —— 表现是「明明有新版本却说已是最新」。
List<int> currentParts = _splitVersion(currentVersion);
List<int> latestParts = _splitVersion(latestVersion);
if (currentParts.isEmpty || latestParts.isEmpty) return false;
// 确保两个版本号长度一致,短的用0补齐
int maxLength = currentParts.length > latestParts.length
? currentParts.length
: latestParts.length;
while (currentParts.length < maxLength) {
currentParts.add(0);
}
while (latestParts.length < maxLength) {
latestParts.add(0);
}
// 从高位到低位比较版本号
for (int i = 0; i < maxLength; i++) {
if (latestParts[i] > currentParts[i]) {
return true;
} else if (latestParts[i] < currentParts[i]) {
return false;
}
}
return false;
} catch (e) {
Logger().d('版本比较错误: $e');
return false;
}
}
}

153
apps/client/lib/modules/ota_upgrade/controllers/ota_download_contropller.dart

@ -1,153 +0,0 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart';
import 'package:logger/web.dart';
/// OTA下载服务
/// 负责处理固件包的下载、文件管理等功能
class OtaDownloadService {
late final Dio _dio;
/// 构造函数
/// 初始化Dio实例并配置SSL证书处理
OtaDownloadService() {
_dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30), // 增加连接超时时间
receiveTimeout: Duration.zero,
sendTimeout: const Duration(seconds: 30), // 增加发送超时时间
headers: {'Accept-Encoding': 'gzip'},
));
// 配置SSL证书验证
(_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
// 处理SSL证书验证
client.badCertificateCallback =
(X509Certificate cert, String host, int port) {
// 在生产环境中,应该进行适当的证书验证
// 这里为了解决证书过期问题,暂时跳过验证
// 建议:在生产环境中实现更安全的证书验证逻辑
Logger().w('SSL证书验证跳过: $host:$port');
return true;
};
return client;
};
}
/// 下载固件包
/// [downloadUrl] 下载地址
/// [onProgress] 下载进度回调
/// [onStatusUpdate] 状态更新回调
/// 返回下载文件的本地路径
/// 抛出异常如果下载失败
Future<String> downloadFirmware({
required String downloadUrl,
required Function(double) onProgress,
required Function(String) onStatusUpdate,
}) async {
onStatusUpdate('准备下载固件包...');
final fileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
const directoryName = "ota_upgrades";
// 获取下载目录
onStatusUpdate('正在获取下载目录...');
final Directory? downloadsDir = await getApplicationSupportDirectory();
if (downloadsDir == null) {
throw FileSystemException('无法访问下载目录');
}
final targetDir = Directory('${downloadsDir.path}/Download/$directoryName');
if (!await targetDir.exists()) {
onStatusUpdate('创建下载目录...');
await targetDir.create(recursive: true);
}
final savePath = '${targetDir.path}/$fileName';
// 检查是否已存在同名文件
onStatusUpdate('检查本地文件...');
if (await _checkExistingFile(targetDir, fileName)) {
onStatusUpdate('文件已存在,准备升级...');
return savePath;
}
// 清理旧文件
onStatusUpdate('清理旧文件...');
await _cleanOldFiles(targetDir);
// 开始下载
onStatusUpdate('开始下载固件包...');
Logger().d('文件保存路径: $savePath');
final options = Options(
receiveTimeout: Duration.zero,
followRedirects: true,
maxRedirects: 5,
);
await _dio.download(
downloadUrl,
savePath,
options: options,
onReceiveProgress: (received, total) {
if (total != -1) {
final progress = received / total;
onProgress(progress);
onStatusUpdate('正在下载固件包... ${(progress * 100).toStringAsFixed(1)}%');
}
},
);
// 验证文件是否下载成功
final file = File(savePath);
if (!await file.exists()) {
throw FileSystemException('文件下载失败,请检查写入权限');
}
onStatusUpdate('下载完成,准备升级...');
return savePath;
}
/// 检查是否存在同名文件
/// [targetDir] 目标目录
/// [fileName] 文件名
/// 返回true表示文件已存在
Future<bool> _checkExistingFile(Directory targetDir, String fileName) async {
try {
final files = await targetDir.list().toList();
for (var entity in files) {
if (await FileSystemEntity.isFile(entity.path)) {
final name = basename(entity.path);
if (name == fileName) {
return true;
}
}
}
return false;
} catch (e) {
Logger().d('检查本地文件失败: $e');
return false;
}
}
/// 清理旧文件
/// [targetDir] 目标目录
/// 抛出异常如果清理失败
Future<void> _cleanOldFiles(Directory targetDir) async {
try {
final files = await targetDir.list().toList();
for (var entity in files) {
if (await FileSystemEntity.isFile(entity.path)) {
await entity.delete();
}
}
} catch (e) {
throw Exception('删除旧文件失败: $e');
}
}
}

0
apps/client/lib/modules/ota_upgrade/controllers/bes_ota_download_service.dart → apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart

424
apps/client/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart

@ -1,424 +0,0 @@
import 'dart:async';
import '/data/services/bes_bluetooth_service.dart';
import 'package:classic_bluetooth/classic_bluetooth.dart';
import 'package:device_jieli/device_jieli.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
// import 'package:logger/web.dart';
import 'package:get_storage/get_storage.dart';
import '../../../data/services/ble_manager.dart';
import 'ota_core_contropller.dart';
import 'ota_download_contropller.dart';
import '../views/ota_upgrade_popup_view.dart';
import '../../../core/utils/logger.dart';
/// OTA升级主控制器 - 重构后的简化版本
/// 负责协调各个服务,管理升级流程的整体状态
class OtaUpgradeController extends GetxController {
// 服务依赖 - 使用新的合并后的服务
final _coreService = OtaCoreService();
final _downloadService = OtaDownloadService();
final _uiService = OtaUpgradePopUp();
final _bleManager = Get.find<BleManager>();
final _jlota = Jielihome.instance;
StreamSubscription<JieliEvent>? _otaEventSub;
// 状态管理
final RxString errorMessage = ''.obs;
final RxString latestVersion = ''.obs;
final RxString currentVersion = ''.obs;
final RxString detailedStatus = ''.obs;
final RxString updatedescription = ''.obs;
final RxDouble downloadProgress = 0.0.obs;
final RxDouble progress = 0.0.obs;
final RxInt type = OtaCoreService.NOT_STARTED.obs;
final RxBool isUpdateAvailable = false.obs;
final RxBool isDownloading = false.obs;
final RxBool isUpgrade = false.obs;
final RxBool isFetchingDeviceInfo = false.obs;
final RxBool showDetailedStatus = false.obs;
final RxBool isBleConnected = true.obs;
final GetStorage _storage = GetStorage();
String _address = '';
String _updatePackageAddress = '';
String? _lastConnectedDeviceImage; // 新增:存储上一次连接设备的图片链接
get lastConnectedDeviceImage => _lastConnectedDeviceImage;
@override
void onInit() {
super.onInit();
_loadLastConnectedDeviceImage(); // 加载持久化的设备图片
_bindBesVersion();
_initializeController();
}
@override
void onClose() {
_jlota.otaCancel();
_otaEventSub?.cancel();
_besVersionWorker?.dispose();
_otaEventSub = null;
_bleManager.setEnterOta(false);
isUpgrade.value = false;
super.onClose();
}
/// 恒玄耳机的固件版本是连接后异步查回来的(`AA 06` → `0x86` 解析),
/// 一次性读取会赶不上:页面先开、版本后到就一直空着。
/// 这里改成订阅,版本一到就刷新界面。
Worker? _besVersionWorker;
void _bindBesVersion() {
if (!Get.isRegistered<BesBluetoothService>()) return;
final bes = BesBluetoothService.to;
void apply(String v) {
if (v.isEmpty || !bes.isConnected) return;
currentVersion.value = v;
isBleConnected.value = true;
// 有了当前版本才能和服务端最新版本比对
isUpdateAvailable.value =
_coreService.isNewVersionAvailable(v, latestVersion.value);
}
apply(bes.deviceVersion.value);
_besVersionWorker = ever<String>(bes.deviceVersion, apply);
// 连上但版本还没回来时,主动补查一次
if (bes.isConnected && bes.deviceVersion.value.isEmpty) {
bes.sendData([0xAA, 0x06]);
}
}
/// 从本地存储加载上一次连接设备的图片链接
void _loadLastConnectedDeviceImage() {
_lastConnectedDeviceImage = _storage.read('last_connected_device_image');
Logger.d(
'OtaUpgradeController', '加载上一次连接设备的图片: $_lastConnectedDeviceImage');
}
/// 初始化控制器
/// 设置事件监听并加载版本信息
/// 恒玄耳机是否连着。恒玄不在杰理 RCSP 链路上,
/// [OtaCoreService.checkBleConnectionStatus] 查的是 BleManager(杰理),
/// 对恒玄恒为 false。
bool get _besConnected =>
Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.isConnected;
Future<void> _initializeController() async {
// ⚠️ 这里原来是「杰理没连上就直接 return」,而本项目用的是恒玄耳机——
// 于是 _loadVersionInfo() 永远不会被调到,固件页从头到尾**没有任何一次
// 服务端拉取**,看起来就是「接口没通」。实际上服务端版本是随
// user_getappconfig 的 products 下发的(product.version /
// updatepackageaddress / updatedescription / isforceupdate),
// 一直都在,只是没人去读。
final bool jieliReady = _coreService.checkBleConnectionStatus();
if (!jieliReady && !_besConnected) {
isBleConnected.value = false;
return;
}
isBleConnected.value = true;
// 杰理的 OTA 事件流只有杰理链路才有意义
if (jieliReady) _listenToOtaEvents();
await _loadVersionInfo();
}
/// 加载版本信息
/// 获取当前设备版本和最新版本信息,检查是否有更新
Future<void> _loadVersionInfo() async {
try {
isFetchingDeviceInfo.value = true;
errorMessage.value = '';
// 获取当前版本信息。
// 恒玄耳机不在杰理 RCSP 链路上,_coreService 读不到它的版本,
// 但连接时已经用 `AA 06` 查过并解析好了,这里直接取。
if (Get.isRegistered<BesBluetoothService>() &&
BesBluetoothService.to.isConnected &&
BesBluetoothService.to.deviceVersion.value.isNotEmpty) {
currentVersion.value = BesBluetoothService.to.deviceVersion.value;
isBleConnected.value = true;
} else {
final currentInfo = await _coreService.getCurrentVersionInfo();
currentVersion.value = currentInfo['currentVersion'];
}
// 获取蓝牙地址
final connectedDevices = await ClassicBluetooth.getConnectedDevices();
if (connectedDevices.isNotEmpty) {
_address = connectedDevices.first.address;
}
// 获取最新版本信息
final latestInfo = await _coreService.getLatestVersionInfo();
latestVersion.value = latestInfo['latestVersion'];
_updatePackageAddress = latestInfo['updatePackageAddress'];
// 设置更新描述信息
updatedescription.value =
latestInfo['updateDescription'] ?? 'otaDefaultUpdateDesc'.tr;
// 检查是否有更新
isUpdateAvailable.value = _coreService.isNewVersionAvailable(
currentVersion.value,
latestVersion.value,
);
} catch (e) {
_handleError('error'.tr, _getUserFriendlyErrorMessage(e));
} finally {
isFetchingDeviceInfo.value = false;
}
}
/// 检查更新
/// 显示更新确认对话框或错误提示
Future<void> checkUpdate() async {
try {
if (latestVersion.value.isNotEmpty) {
_uiService.showUpdateDialog(latestVersion.value, _startUpdateProcess);
} else {
_uiService.showErrorSnackbar('error'.tr, 'otaGetLatestVersionFailed'.tr,
backgroundColor: Colors.orange[800]);
}
} catch (e) {
_uiService.showErrorSnackbar('error'.tr, 'otaGetVersionInfoFailed'.tr,
backgroundColor: Colors.orange[800]);
}
}
/// 开始更新流程的公共方法
/// 供视图调用的更新入口点
Future<void> startUpdate() async {
// 恒玄耳机目前**没有固件推送通道**:bluetooth_manager 插件里没有任何
// OTA 实现,下面这条流程整条是杰理 RCSP 的(下载 → RCSP 分包 → 重启配对)。
// 让它跑下去只会在中途失败,用户看到的是一串莫名其妙的错误码。
// 所以这里明确拦一下:版本对比与更新提示照常给,升级动作说清楚做不了。
if (_besConnected && !_coreService.checkBleConnectionStatus()) {
_uiService.showErrorSnackbar(
'tip'.tr,
'otaBesNotSupported'.tr,
backgroundColor: Colors.orange[800],
);
return;
}
//立即给用户状态反馈
showDetailedStatus.value = true;
detailedStatus.value = 'otaCheckingDeviceStatus'.tr;
await _startUpdateProcess();
}
/// 检查设备状态后开始下载和升级
Future<void> _startUpdateProcess() async {
// 检查设备电量
if (!_coreService.checkBatteryLevel()) {
return;
}
await _startDownload();
}
/// 开始下载固件
/// 初始化OTA环境并下载固件包
Future<void> _startDownload() async {
try {
await _bleManager.setEnterOta(true);
await Future.delayed(const Duration(seconds: 2));
// device_jieli 已经在全局 InitialBinding 里 initialize 过;
// 这里无需重复 init —— 直接订阅 OTA 事件流即可。
isDownloading.value = true;
showDetailedStatus.value = true;
type.value = OtaCoreService.DOWNLOADING_UP_FILES;
final filePath = await _downloadService.downloadFirmware(
downloadUrl: _updatePackageAddress,
onProgress: (progress) {
downloadProgress.value = progress;
},
onStatusUpdate: (status) {
detailedStatus.value = status;
},
);
isDownloading.value = false;
await _startUpgrading(filePath);
} catch (e) {
isDownloading.value = false;
detailedStatus.value = 'otaDownloadFailedCheckNetwork'.tr;
showDetailedStatus.value = false;
_handleError('otaDownloadFailed'.tr, e.toString());
Logger.d('OtaUpgradeController', '下载失败: $e');
}
}
/// 开始升级
/// [filePath] 固件文件路径
/// 启动OTA升级过程
Future<void> _startUpgrading(String filePath) async {
detailedStatus.value = 'otaPreparingConnection'.tr;
await Future.delayed(const Duration(milliseconds: 500));
detailedStatus.value = 'otaConnectingDevice'.tr;
_jlota.otaStart(address: _address, firmwareFilePath: filePath);
}
/// 停止升级
/// 关闭OTA连接
void stopUpgrading() {
_jlota.otaCancel();
}
/// 重试连接
/// 清除错误信息并重新加载版本信息
void retryConnection() {
errorMessage.value = '';
_loadVersionInfo();
}
/// 获取状态描述
/// [type] 状态类型
/// 返回对应的状态描述文本
String getStatusMessage(int type) {
return OtaCoreService.getStatusMessage(type);
}
/// 监听OTA事件
/// 订阅 device_jieli 的统一事件流,过滤 OTA 相关事件并转发到分发器。
void _listenToOtaEvents() {
_otaEventSub?.cancel();
_otaEventSub = _jlota.events.listen(_handleOtaEvent);
}
/// 处理 device_jieli 抛出的 OTA / 连接事件。
/// - [OtaStateEvent]: 进度 / 状态机推进
/// - [OtaErrorEvent]: 升级失败
/// - [ConnectionStateEvent]: 设备连接 / 断开(OTA 期间用于"中断"指示)
void _handleOtaEvent(JieliEvent event) {
if (event is OtaStateEvent) {
switch (event.state) {
case OtaState.entering:
case OtaState.inquiring:
case OtaState.notifyingSize:
type.value = OtaCoreService.START_UPGRADING;
isUpgrade.value = true;
detailedStatus.value = 'otaUpgradeStarted'.tr;
break;
case OtaState.transferring:
if (event.percent >= 0) {
// device_jieli 发的是 0..100 整数;UI 这边按 0..1 浮点用。
progress.value = event.percent / 100.0;
type.value = OtaCoreService.ONUPGRADE;
isUpgrade.value = true;
detailedStatus.value = 'otaUpgradeProgressWithPercent'
.trParams({'percent': event.percent.toStringAsFixed(1)});
}
break;
case OtaState.verifying:
detailedStatus.value = 'otaUpgradeStarted'.tr;
break;
case OtaState.rebooting:
// 设备重启等待,仍在升级中
isUpgrade.value = true;
break;
case OtaState.done:
_handleUpgradeSuccess();
break;
case OtaState.failed:
_handleUpgradeError(-1);
break;
case OtaState.cancelled:
isUpgrade.value = false;
showDetailedStatus.value = false;
break;
case OtaState.idle:
break;
}
return;
}
if (event is OtaErrorEvent) {
_handleUpgradeError(event.code);
return;
}
if (event is ConnectionStateEvent) {
if (event.state == ConnectionStateEvent.connectionDisconnect &&
isUpgrade.value) {
_handleConnectionLost();
} else if (event.state == ConnectionStateEvent.connectionOk &&
isUpgrade.value) {
_handleConnectionSuccess();
}
}
}
/// 处理升级成功
/// 更新状态并显示成功对话框
void _handleUpgradeSuccess() {
type.value = OtaCoreService.UPGRADE_SUCCESS;
isUpgrade.value = false;
detailedStatus.value = 'otaUpgradeSuccessComplete'.tr;
showDetailedStatus.value = false;
// 更新版本信息
currentVersion.value = latestVersion.value;
isUpdateAvailable.value = false;
// 显示成功对话框
_uiService.showUpgradeSuccessDialog();
}
/// 处理升级错误
/// [errorCode] 错误代码
/// 更新状态并显示错误提示
void _handleUpgradeError(dynamic errorCode) {
type.value = OtaCoreService.UPGRADE_FAILED;
isUpgrade.value = false;
detailedStatus.value = 'otaUpgradeFailedWithCode'.trParams({'code': errorCode.toString()});
showDetailedStatus.value = false;
_uiService.showErrorSnackbar(
'otaUpgradeFailedTitle'.tr,
'otaUpgradeFailedDesc'.trParams({'code': errorCode.toString()}),
);
}
/// 处理连接断开
/// 更新连接状态
void _handleConnectionLost() {
type.value = OtaCoreService.CONNECTION_DISCONNECTION;
isUpgrade.value = false;
detailedStatus.value = 'otaDeviceDisconnected'.tr;
showDetailedStatus.value = false;
}
/// 处理连接成功
/// 更新连接状态
void _handleConnectionSuccess() {
type.value = OtaCoreService.CONNECTION_SUCCESS;
isUpgrade.value = true;
detailedStatus.value = 'otaDeviceConnected'.tr;
}
/// 统一错误处理
/// [title] 错误标题
/// [message] 错误信息
/// 设置错误状态并显示错误提示
void _handleError(String title, String message) {
errorMessage.value = message;
_uiService.showErrorSnackbar(title, message);
}
String _getUserFriendlyErrorMessage(Object error) {
if (error is OtaCoreException) {
switch (error.type) {
case OtaCoreErrorType.deviceInfoUnavailable:
return 'otaDeviceInfoUnavailable'.tr;
case OtaCoreErrorType.productInfoUnavailable:
return 'otaProductInfoUnavailable'.tr;
case OtaCoreErrorType.firmwareVersionFormatInvalid:
return 'otaFirmwareVersionInvalid'.tr;
}
}
return 'otaVersionInfoFailed'.tr;
}
}

0
apps/client/lib/modules/ota_upgrade/views/bes_ota_upgrade_view.dart → apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart

1132
apps/client/lib/modules/ota_upgrade/views/ota_upgrade_view.dart

File diff suppressed because it is too large

9
apps/client/lib/modules/qqmusic/bindings/qqmusic_playlist_binding.dart

@ -1,9 +0,0 @@
import 'package:get/get.dart';
import '../controllers/qqmusic_playlist_controller.dart';
class QQMusicPlaylistBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<QQMusicPlaylistController>(() => QQMusicPlaylistController());
}
}

237
apps/client/lib/modules/qqmusic/controllers/qqmusic_playlist_controller.dart

@ -1,237 +0,0 @@
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import '../../../data/models/song_model.dart';
import '../../../data/services/qq_music.dart';
import '../../../core/utils/logger.dart';
class QQMusicPlaylistController extends GetxController {
final QqMusicService _qqMusicService = QqMusicService.to;
// 登录相关
final isLoggedIn = false.obs;
final userInfo = Rxn<Map<String, dynamic>>();
// 界面维护的播放列表状态
RxList<SongModel> playlist = <SongModel>[].obs;
RxInt currentIndex = 0.obs;
// 界面显示的当前歌曲(可能与实际播放的不同)
Rxn<SongModel> displayCurrentSong = Rxn<SongModel>();
// 界面播放状态(当前播放歌曲不在列表中时为未播放状态)
RxBool displayIsPlaying = false.obs;
// 从全局服务获取实际播放状态(仅用于同步)
RxBool get actualIsPlaying => _qqMusicService.isPlaying;
RxInt get currentPosition => _qqMusicService.currentPosition;
RxInt get totalDuration => _qqMusicService.totalDuration;
Rxn<SongModel> get actualCurrentSong => _qqMusicService.currentSong;
@override
void onInit() {
super.onInit();
_initializePlaylist();
print('🎵 [QQMusicPlaylistController] 控制器初始化完成');
}
/// 初始化播放列表
void _initializePlaylist() {
// 从路由参数获取播放列表
final arguments = Get.arguments;
if (arguments != null && arguments['playlist'] != null) {
final List<SongModel> songList = arguments['playlist'];
// 更新播放列表
playlist.assignAll(songList);
// 检查当前播放歌曲是否在列表中
_syncDisplayStateWithActualPlayback();
} else {
Logger.w('QQMusicPlaylist', '未接收到播放列表参数');
_setDefaultDisplayState();
}
// 界面初始化时同步播放状态和进度
_qqMusicService.syncCurrentPlayInfo();
}
/// 同步显示状态与实际播放状态
void _syncDisplayStateWithActualPlayback() {
if (playlist.isEmpty) {
_setDefaultDisplayState();
return;
}
final actualSong = actualCurrentSong.value;
if (actualSong != null) {
// 查找当前播放歌曲在列表中的位置
final index = playlist.indexWhere((song) => song.mid == actualSong.mid);
if (index != -1) {
// 当前播放歌曲在列表中
currentIndex.value = index;
displayCurrentSong.value = playlist[index];
displayIsPlaying.value = actualIsPlaying.value;
Logger.d('QQMusicPlaylist', '当前播放歌曲在列表中,索引: $index');
} else {
// 当前播放歌曲不在列表中,显示列表第一个,状态为未播放
_setDefaultDisplayState();
Logger.d('QQMusicPlaylist', '当前播放歌曲不在列表中,显示默认状态');
}
} else {
// 没有当前播放歌曲,显示列表第一个,状态为未播放
_setDefaultDisplayState();
Logger.d('QQMusicPlaylist', '无当前播放歌曲,显示默认状态');
}
}
/// 设置默认显示状态
void _setDefaultDisplayState() {
if (playlist.isNotEmpty) {
currentIndex.value = 0;
displayCurrentSong.value = playlist.first;
displayIsPlaying.value = false; // 默认为未播放状态
Logger.d('QQMusicPlaylist', '设置默认显示状态: ${playlist.first.name}');
} else {
currentIndex.value = 0;
displayCurrentSong.value = null;
displayIsPlaying.value = false;
Logger.w('QQMusicPlaylist', '播放列表为空,无法设置默认状态');
}
}
/// 音乐状态变化回调
void _onMusicStateChanged() {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!isClosed) {
// 重新同步显示状态
_syncDisplayStateWithActualPlayback();
update();
}
});
}
@override
void onClose() {
// 移除状态监听器
super.onClose();
}
/// 播放歌曲
Future<void> playSong(SongModel song, {List<SongModel>? songList}) async {
try {
displayIsPlaying.value = true;
// 使用传入的歌曲列表或当前播放列表
final List<SongModel> finalSongList = songList ?? playlist.toList();
final songMids = finalSongList.map((s) => s.mid).toList();
final playIndex = finalSongList.indexOf(song);
// 歌曲在当前列表中,更新界面状态
currentIndex.value = playIndex;
displayCurrentSong.value = song;
final result = await _qqMusicService.playSongs(
songMids: songMids,
index: playIndex,
songList: finalSongList,
currentSongModel: song,
);
if (result['success'] == true) {
displayIsPlaying.value = true;
// _qqMusicService.syncCurrentPlayInfo();
// Get.snackbar('开始播放', song.name);
} else {
displayIsPlaying.value = false;
// Get.snackbar('播放失败', result['error'] ?? '未知错误');
}
} catch (e) {
displayIsPlaying.value = false;
// Get.snackbar('播放失败', e.toString());
}
}
/// 播放列表中的指定歌曲
Future<void> playPlaylistSong(int index) async {
if (index >= 0 && index < playlist.length) {
await playSong(playlist[index]);
}
}
/// 暂停/继续播放
Future<void> togglePlayPause() async {
// 检查当前显示的歌曲是否正在播放
final displaySong = displayCurrentSong.value;
final actualSong = actualCurrentSong.value;
if (displaySong != null &&
actualSong != null &&
displaySong.mid == actualSong.mid) {
// 显示的歌曲正在播放,执行暂停/继续
if (actualIsPlaying.value) {
await _qqMusicService.pauseMusic();
displayIsPlaying.value = false;
} else {
await _qqMusicService.resumeMusic();
displayIsPlaying.value = true;
}
} else {
// 显示的歌曲不是当前播放的,播放显示的歌曲
if (displaySong != null) {
await playSong(displaySong);
}
}
}
/// 下一首(列表内)
Future<void> nextSong() async {
if (playlist.isNotEmpty) {
final nextIndex = (currentIndex.value + 1) % playlist.length;
await playPlaylistSong(nextIndex);
}
}
/// 上一首(列表内)
Future<void> previousSong() async {
if (playlist.isNotEmpty) {
final prevIndex =
currentIndex.value > 0 ? currentIndex.value - 1 : playlist.length - 1;
await playPlaylistSong(prevIndex);
}
}
/// 停止播放
Future<void> stopMusic() async {
await _qqMusicService.stopMusic();
displayIsPlaying.value = false;
}
/// 格式化时间
String formatDuration(int milliseconds) {
return _qqMusicService.formatDuration(milliseconds);
}
/// 获取当前显示的播放状态(用于UI显示)
bool get isCurrentSongPlaying {
final displaySong = displayCurrentSong.value;
final actualSong = actualCurrentSong.value;
// 只有当显示的歌曲与实际播放的歌曲相同时,才显示播放状态
if (displaySong != null &&
actualSong != null &&
displaySong.mid == actualSong.mid) {
return actualIsPlaying.value;
}
return false;
}
/// 检查歌曲是否在当前播放列表中
bool isSongInPlaylist(SongModel song) {
return playlist.any((s) => s.mid == song.mid);
}
/// 更新播放列表(支持多个播放列表场景)
void updatePlaylist(List<SongModel> newPlaylist) {
playlist.assignAll(newPlaylist);
_syncDisplayStateWithActualPlayback();
Logger.d('QQMusicPlaylist', '播放列表已更新,歌曲数量: ${newPlaylist.length}');
}
}

651
apps/client/lib/modules/qqmusic/views/qqmusic_playlist_view.dart

@ -1,651 +0,0 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '/modules/agent/widgets/circular_progress_button.dart';
import '/modules/qqmusic/controllers/qqmusic_playlist_controller.dart';
import '/data/models/song_model.dart'; // Use the data model instead
import '../../../core/utils/logger.dart';
class QqmusicPlaylistView extends GetView<QQMusicPlaylistController> {
const QqmusicPlaylistView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
ScreenUtil.init(context, designSize: const Size(375, 812));
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 20),
onPressed: () => Navigator.pop(context),
),
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
foregroundColor: isDarkMode ? Colors.white : Colors.black,
elevation: 0,
),
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
body: controller.playlist.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_off,
size: 64.w,
color: Colors.grey[400],
),
SizedBox(height: 16.h),
Text(
'noMusic'.tr,
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey[600],
),
),
],
),
)
: Column(
children: [
// 上部:当前播放音乐详情
Container(
height: MediaQuery.of(context).size.height * 0.38,
child: _buildCurrentPlayingSection(isDarkMode),
),
// 中部:播放全部按钮
_buildPlayAllSection(isDarkMode),
// 下部:歌单列表
Expanded(
child: _buildPlaylistSection(isDarkMode),
),
],
),
// 添加迷你播放器
// bottomNavigationBar: _buildMiniPlayer(isDarkMode),
);
}
// 构建当前播放音乐详情部分
Widget _buildCurrentPlayingSection(bool isDarkMode) {
return SingleChildScrollView(
child: Obx(() {
// 使用 displayCurrentSong 而不是 actualCurrentSong
final displayCurrentSong = controller.displayCurrentSong.value;
if (displayCurrentSong == null) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 110.w,
color: Colors.grey[400],
),
SizedBox(height: 12.h),
Text(
'noMusicPlaying'.tr,
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey[600],
),
),
],
),
);
}
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 封面图片 - 使用 displayCurrentSong
_buildCoverImage(displayCurrentSong.albumPic ?? '', isDarkMode,
size: 110.w),
SizedBox(height: 12.h),
// 歌曲信息 - 使用 displayCurrentSong
_buildSongInfo(displayCurrentSong.name, displayCurrentSong.artist,
isDarkMode),
SizedBox(height: 16.h),
// 进度条
_buildProgressBar(isDarkMode),
SizedBox(height: 15.h),
// 播放控制按钮
_buildPlayControls(isDarkMode),
],
),
);
}),
);
}
// 构建播放全部按钮部分
Widget _buildPlayAllSection(bool isDarkMode) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton.icon(
onPressed: () async {
if (controller.playlist.isNotEmpty) {}
},
icon: Icon(Icons.play_arrow, color: Colors.white, size: 20.w),
label: Text(
'playAll'.tr, // 播放全部
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(Get.context!).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.r),
),
elevation: 3,
shadowColor: Theme.of(Get.context!).primaryColor.withAlpha(102),
),
),
),
);
}
// 构建歌单列表部分
Widget _buildPlaylistSection(bool isDarkMode) {
return GetBuilder<QQMusicPlaylistController>(
builder: (controller) {
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h),
itemCount: controller.playlist.length,
itemBuilder: (context, index) {
final song = controller.playlist[index];
return _buildMusicItem(
context, controller.playlist, song, index, isDarkMode);
},
);
},
);
}
Widget _buildMusicItem(
BuildContext context,
List<SongModel> songs,
SongModel song,
int index,
bool isDarkMode,
) {
return GestureDetector(
onTap: () async {
await controller.playSong(song, songList: songs);
},
child: Container(
margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.white,
borderRadius: BorderRadius.circular(14.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 封面图片
Container(
width: 52.w,
height: 52.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.r),
color: isDarkMode ? Colors.grey[700] : Colors.grey[200],
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.08),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10.r),
child: Image.network(
song.albumPic ?? '',
width: 52.w,
height: 52.w,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.music_note,
color: isDarkMode
? Colors.grey[500]
: Colors.grey[600],
size: 24.w,
);
},
),
),
// 右下角音乐图标
Positioned(
right: 0.w,
bottom: 0.w,
child: Container(
width: 14.w,
height: 14.w,
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
],
)),
SizedBox(width: 14.w),
// 歌曲信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
song.name,
style: TextStyle(
fontSize: 15.5.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 3.h),
Text(
song.artist,
style: TextStyle(
fontSize: 13.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
height: 1.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(width: 14.w),
// 播放按钮
Obx(() {
// 使用 actualCurrentSong 的 mid 来判断是否是当前播放的歌曲
final isThactualIsPlaying = controller.actualIsPlaying.value &&
controller.actualCurrentSong.value?.mid == song.mid;
final currentProgress = isThactualIsPlaying
? controller.totalDuration.value > 0
? controller.currentPosition.value /
controller.totalDuration.value
: 0.0
: 0.0;
return CircularProgressButton(
isPlaying: isThactualIsPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 40.w,
strokeWidth: 2.5,
onPlayPause: () async {
try {
Logger.d('MusicPlaylistView',
'播放按钮被点击,当前播放状态: ${controller.actualIsPlaying.value}');
Logger.d('MusicPlaylistView',
'当前歌曲ID: ${controller.actualCurrentSong.value?.mid}, 点击歌曲ID: ${song.mid}');
if (controller.actualIsPlaying.value) {
if (controller.actualCurrentSong.value?.mid == song.mid) {
Logger.d('MusicPlaylistView', '暂停当前播放的歌曲');
await controller.togglePlayPause();
} else {
Logger.d('MusicPlaylistView', '播放新歌曲');
await controller.playSong(song, songList: songs);
}
} else {
Logger.d('MusicPlaylistView', '开始播放歌曲');
await controller.playSong(song, songList: songs);
}
} catch (e) {
Logger.e('MusicPlaylistView', '播放音乐时发生错误: $e');
}
},
onSeek: isThactualIsPlaying ? (seekProgress) {} : null,
);
}),
],
),
),
);
}
// 构建封面图片
Widget _buildCoverImage(String coverUrl, bool isDarkMode,
{required double size}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
blurRadius: 15,
spreadRadius: 2,
offset: const Offset(0, 6),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: coverUrl.isNotEmpty
? Image.network(
coverUrl,
fit: BoxFit.cover,
width: size,
height: size,
errorBuilder: (context, error, stackTrace) {
return _buildDefaultCover(isDarkMode, size: size);
},
)
: _buildDefaultCover(isDarkMode, size: size),
),
// 右下角音乐图标
Positioned(
right: 0.w,
bottom: 0.w,
child: Container(
width: size * 0.3,
height: size * 0.3,
child: ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
),
],
),
);
}
// 构建默认封面
Widget _buildDefaultCover(bool isDarkMode, {required double size}) {
return Container(
color: isDarkMode ? Colors.grey[800] : Colors.grey[300],
child: Icon(
Icons.music_note,
size: size * 0.55,
color: isDarkMode ? Colors.grey[600] : Colors.grey[500],
),
);
}
// 构建歌曲信息
Widget _buildSongInfo(String title, String artist, bool isDarkMode) {
return Column(
children: [
Text(
title,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 6.h),
Text(
artist,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
// 构建进度条 - 参考qqmusic_test的简洁设计
Widget _buildProgressBar(bool isDarkMode) {
return Obx(() {
// 计算进度值,确保在0.0-1.0范围内
double progressValue = 0.0;
if (controller.totalDuration.value > 0) {
progressValue =
(controller.currentPosition.value / controller.totalDuration.value)
.clamp(0.0, 1.0);
}
return Column(
children: [
// 时间显示
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(controller.currentPosition.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
Text(
_formatDuration(controller.totalDuration.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
SizedBox(height: 8.h),
// 进度条 - 使用LinearProgressIndicator,更简洁
LinearProgressIndicator(
value: progressValue,
backgroundColor: isDarkMode ? Colors.grey[700] : Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(Get.context!).primaryColor),
minHeight: 4.h,
),
],
);
});
}
// 构建迷你播放器 - 参考qqmusic_test的设计
Widget _buildMiniPlayer(bool isDarkMode) {
return Obx(() {
if (controller.actualCurrentSong.value == null) {
return const SizedBox.shrink();
}
final song = controller.actualCurrentSong.value!;
return Container(
height: 80.h,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Column(
children: [
// 进度条
LinearProgressIndicator(
value: controller.totalDuration.value > 0
? (controller.currentPosition.value /
controller.totalDuration.value)
.clamp(0.0, 1.0)
: 0,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(Get.context!).primaryColor),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Row(
children: [
// 歌曲信息
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
song.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
Text(
song.artist,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isDarkMode
? Colors.grey[400]
: Colors.grey[600],
fontSize: 12.sp,
),
),
],
),
),
// 控制按钮
Row(
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: controller.previousSong,
color: isDarkMode ? Colors.white : Colors.black,
),
IconButton(
icon: Icon(
controller.actualIsPlaying.value
? Icons.pause
: Icons.play_arrow,
),
onPressed: controller.togglePlayPause,
color: Theme.of(Get.context!).primaryColor,
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: controller.nextSong,
color: isDarkMode ? Colors.white : Colors.black,
),
],
),
],
),
),
),
],
),
);
});
}
// 构建播放控制按钮
Widget _buildPlayControls(bool isDarkMode) {
return Obx(() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () async {
await controller.previousSong();
},
iconSize: 32.w,
padding: EdgeInsets.all(8.w), // 增大触摸区域
icon: Icon(
Icons.skip_previous,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
SizedBox(width: 16.w),
Container(
width: 56.w,
height: 56.w,
decoration: BoxDecoration(
color: Theme.of(Get.context!).primaryColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Theme.of(Get.context!).primaryColor.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 1,
offset: const Offset(0, 3),
),
],
),
child: IconButton(
onPressed: () async {
await controller.togglePlayPause();
},
padding: EdgeInsets.zero,
icon: Icon(
controller.actualIsPlaying.value
? Icons.pause
: Icons.play_arrow,
size: 28.w,
color: Colors.white,
),
),
),
SizedBox(width: 16.w),
IconButton(
onPressed: () async {
await controller.nextSong();
},
iconSize: 32.w,
padding: EdgeInsets.all(8.w), // 增大触摸区域
icon: Icon(
Icons.skip_next,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
],
);
});
}
// ...(其他辅助方法保持不变,如_formatDuration, _togglePlayPause等)
// 格式化时间显示
String _formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}

160
apps/client/lib/modules/realtime/README.md

@ -1,160 +0,0 @@
# Realtime 实时语音聊天模块
## ⚠️ 当前状态
**此模块为功能预览版本,提供完整的实时语音对话功能。**
通过原生插件实现语音识别、AI对话和语音合成的完整语音交互体验。
## 概述
Realtime模块实现了类似ChatGPT app的实时语音对话功能,当前版本提供:
- ✅ **完整语音交互** - 实时语音识别、AI对话和语音合成
- ✅ **状态管理** - 聆听、思考、回答状态的可视化
- ✅ **动画效果** - 基于Shader的流动云效果,支持音频振幅驱动
- ✅ **字幕显示** - 可选择开启/关闭的字幕模式
- ✅ **智能UI** - 根据连接状态自动调整按钮可用性
## 界面设计
### 布局结构
- **顶部状态区域** - 显示当前状态和字幕模式切换按钮
- **中间可视化区域** - 圆形Shader动画,根据音频振幅呈现呼吸效果
- **底部控制区域** - 主控制按钮和状态指示器
### 状态指示
- 🔵 **聆听状态** - 蓝色动画,接收用户语音输入
- 🟠 **思考状态** - 橙色动画,AI处理中
- 🟢 **回答状态** - 绿色动画,AI语音输出
- ⚪ **空闲状态** - 灰色静态,等待交互
### 字幕模式
- **默认关闭** - 初始状态下不显示任何字幕内容
- **手动切换** - 通过右上角按钮手动开启/关闭字幕
- **完整隐藏** - 关闭时字幕区域完全不显示,不占用界面空间
- **智能布局** - 开启时圆形动画自动上移,字幕区域占据下半屏
### 交互控制
- **录音按钮** - 仅在成功连接AI服务器且开始录音后可用
- **连接状态** - 未连接时录音按钮显示不可用状态
- **状态反馈** - 按钮颜色和可点击性根据连接状态动态调整
## 使用方法
### 路由导航
```dart
// 跳转到实时语音对话页面
Get.toNamed(Routes.realtime);
```
### 交互流程
1. **自动连接** - 页面加载后自动尝试连接AI服务器
2. **等待连接** - 录音按钮在连接成功前保持不可用状态
3. **开始录音** - 连接成功后自动开始录音,录音按钮变为可用
4. **语音对话** - 用户说话 → AI处理 → 语音回复
5. **字幕控制** - 可随时通过右上角按钮切换字幕显示
## 技术架构
### 控制器 (RealtimeController)
- **连接管理**: WebSocket连接到AI服务器
- **状态同步**: `isConnected`, `isListening`, `isSpeaking`
- **字幕控制**: `isSubtitleMode` 默认为false,用户手动控制
- **消息管理**: 智能去重,避免流式响应重复显示
- **音频处理**: 原生层计算RMS值,Flutter层接收并控制动画
### 视图组件
- **RealtimeView** - 主视图容器,响应式布局
- **ShaderMicFlow** - 基于GLSL Shader的流动云动画组件
- **AutoScrollList** - 自动滚动的字幕列表
### 原生集成
- **RealtimeService** - 原生音频处理和WebSocket通信
- **音频格式** - 16kHz, 1声道, 16位PCM
- **实时传输** - 低延迟音频流处理
- **RMS计算** - 原生层实时计算音频振幅
## 默认行为
### 字幕模式
- **初始状态**: 关闭 (`isSubtitleMode.value = false`)
- **显示逻辑**: 仅在用户手动开启时显示字幕内容
- **空间占用**: 关闭时字幕区域高度为0,不影响其他UI元素
- **切换效果**: 平滑动画过渡,圆形动画位置和大小同步调整
### 按钮状态
- **录音按钮**: 默认不可用,连接成功且开始录音后才可点击
- **状态指示**: 通过颜色和图标变化反映当前可用性
- **交互反馈**: 不可用时点击无效果,避免误操作
## 开发计划
### 已完成功能 ✅
- [x] 完整语音交互流程
- [x] 实时WebSocket通信
- [x] Shader流动云动画
- [x] RMS音频振幅计算
- [x] 智能UI状态管理
- [x] 字幕模式控制
- [x] 消息去重优化
### 计划增强 ⏳
- [ ] 多语言语音识别
- [ ] 语音中断检测
- [ ] 音频质量自适应
- [ ] 离线降级处理
- [ ] 语音情感识别
## 注意事项
1. **网络依赖** - 需要连接到AI服务器 (ws://192.168.1.11:8000/ws)
2. **麦克风权限** - 需要获取麦克风使用权限
3. **音频会话** - 自动管理音频会话,支持蓝牙设备
4. **资源管理** - 页面关闭时自动清理音频资源和网络连接
## 自定义配置
### 修改服务器地址
```dart
// 在RealtimeController中修改连接URL
await _realtimeService.initialize(
serverUrl: 'ws://your-server:port/ws',
// ... 其他参数
);
```
### 调整UI布局
```dart
// 修改字幕模式下圆形动画位置
alignment: controller.isSubtitleMode.value
? const Alignment(0, -0.9) // 调整Y轴位置
: const Alignment(0, -0.25),
```
### 音频参数调优
```dart
// 在初始化时调整音频参数
sampleRate: 16000, // 采样率
channels: 1, // 声道数
bitsPerSample: 16, // 位深度
```
## 技术栈
- **Flutter SDK**: >= 3.0.0
- **GetX**: >= 4.6.5 (状态管理)
- **Flutter Shaders**: >= 0.0.6 (Shader动画)
- **原生插件**: Realtime服务 (音频处理)
- **WebSocket**: 实时通信协议
## 版本信息
- **当前版本**: v1.0.0 (Production)
- **最后更新**: 2024年
- **状态**: 完整功能实现

10
apps/client/lib/modules/realtime/bindings/realtime_binding.dart

@ -1,10 +0,0 @@
import 'package:get/get.dart';
import '../controllers/realtime_controller.dart';
class RealtimeBinding extends Bindings {
@override
void dependencies() {
// 注册实时语音聊天控制器
Get.lazyPut<RealtimeController>(() => RealtimeController());
}
}

406
apps/client/lib/modules/realtime/controllers/realtime_controller.dart

@ -1,406 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:realtime/realtime.dart';
import '../../../core/utils/logger.dart';
class RealtimeController extends GetxController with GetTickerProviderStateMixin {
static const int _sampleRate = 16000;
// Realtime服务实例
final RealtimeService _realtimeService = RealtimeService();
// 音频振幅值(RMS)
final RxDouble audioAmplitude = 0.0.obs;
// 连接状态
final RxBool isConnected = false.obs;
final RxBool isConnecting = false.obs;
// 语音状态
final RxBool isListening = false.obs;
final RxBool isSpeaking = false.obs;
// UI状态
final RxString currentStatus = ''.obs; // 移除默认文字
final RxString userSpeechText = ''.obs;
final RxBool isUserSpeechFinal = true.obs;
final RxList<String> aiResponseMessages = <String>[].obs;
final RxBool isSubtitleMode = true.obs;
// 字幕显示用的最新消息
final RxString latestUserMessage = ''.obs;
final RxString latestAiMessage = ''.obs;
// 实时字幕更新用的临时消息
final RxString partialUserMessage = ''.obs;
final RxString streamingAiMessage = ''.obs;
// 状态监听
StreamSubscription<RealtimeEvent>? _eventSubscription;
@override
void onInit() {
super.onInit();
_setupEventListener();
// 自动开始连接
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoConnect();
});
}
@override
void onClose() {
_cleanup();
super.onClose();
}
/// 设置事件监听器
void _setupEventListener() {
_eventSubscription = _realtimeService.eventStream.listen(
(event) {
_handleRealtimeEvent(event);
},
onError: (error) {
Logger.error('Realtime事件流错误: $error');
},
);
}
/// 处理Realtime事件
void _handleRealtimeEvent(RealtimeEvent event) {
switch (event.type) {
case 'connectionStatusChanged':
_handleConnectionStatusChanged(event.data);
break;
case 'voiceStatusChanged':
_handleVoiceStatusChanged(event.data);
break;
case 'textReceived':
_handleTextReceived(event.data);
break;
case 'audioRms':
_handleAudioRms(event.data);
break;
case 'error':
_handleError(event.data);
break;
}
}
/// 自动连接
Future<void> _autoConnect() async {
currentStatus.value = ''; // 不显示任何文字
await connect();
}
/// 处理连接状态变化
void _handleConnectionStatusChanged(String status) {
switch (status) {
case 'connected':
isConnected.value = true;
isConnecting.value = false;
currentStatus.value = ''; // 不显示任何文字
// 连接成功后自动开始录音
Timer(const Duration(milliseconds: 500), () {
startListening();
});
break;
case 'connecting':
isConnecting.value = true;
currentStatus.value = ''; // 不显示任何文字
break;
case 'disconnected':
isConnected.value = false;
isConnecting.value = false;
currentStatus.value = ''; // 不显示任何文字
_resetState();
break;
case 'error':
isConnected.value = false;
isConnecting.value = false;
currentStatus.value = '连接失败,请检查网络'; // 保留错误提示
_resetState();
Logger.error('Vocode服务器连接错误');
break;
}
}
/// 处理语音状态变化
void _handleVoiceStatusChanged(String status) {
switch (status) {
case 'recording':
isListening.value = true;
isSpeaking.value = false;
currentStatus.value = ''; // 不显示任何文字
break;
case 'processing':
isListening.value = false;
currentStatus.value = ''; // 不显示任何文字
break;
case 'playing':
isListening.value = false;
isSpeaking.value = true;
currentStatus.value = ''; // 不显示任何文字
break;
case 'idle':
isListening.value = false;
isSpeaking.value = false;
currentStatus.value = ''; // 不显示任何文字
// 空闲时音频振幅归零
audioAmplitude.value = 0.0;
break;
}
}
/// 处理收到的文本消息 - 标准Vocode WebSocket协议
void _handleTextReceived(dynamic payload) {
if (payload is String) {
try {
// 解析JSON消息
final Map<String, dynamic> json = jsonDecode(payload);
final String messageType = json['type'] ?? '';
switch (messageType) {
case 'websocket_transcript':
// 处理统一的文本消息格式
final String text = json['text'] ?? '';
final String sender = json['sender'] ?? '';
final bool isFinal = json['is_final'] ?? false;
// final dynamic timestamp = json['timestamp']; // 保留供未来使用
if (sender.toLowerCase() == 'human') {
// 用户语音识别消息
if (isFinal) {
// ASR最终结果
if (text.isNotEmpty) {
latestUserMessage.value = text;
userSpeechText.value = text;
isUserSpeechFinal.value = true;
}
// 清空部分消息
partialUserMessage.value = '';
} else {
// ASR实时识别
if (text.isNotEmpty) {
partialUserMessage.value = text;
isUserSpeechFinal.value = false;
}
}
} else if (sender.toLowerCase() == 'bot') {
// AI回复消息
if (isFinal) {
// LLM回复完成标识 (text为空)
if (streamingAiMessage.value.isNotEmpty) {
// 将流式消息确认为最终消息
latestAiMessage.value = streamingAiMessage.value;
if (aiResponseMessages.isNotEmpty) {
aiResponseMessages[aiResponseMessages.length - 1] = streamingAiMessage.value;
} else {
aiResponseMessages.add(streamingAiMessage.value);
}
streamingAiMessage.value = '';
}
} else {
// LLM流式回复
if (text.isNotEmpty) {
streamingAiMessage.value += text;
}
}
}
break;
case 'websocket_ready':
// 连接就绪
break;
case 'websocket_stop':
// 会话结束
break;
case 'websocket_start':
// 会话开始
break;
default:
Logger.error('收到未知消息类型: "$messageType"');
}
} catch (e) {
Logger.error('解析WebSocket消息失败: $e');
}
} else {
Logger.error('收到非字符串类型的payload: ${payload.runtimeType}');
}
}
/// 处理音频RMS值
void _handleAudioRms(dynamic rms) {
if (rms is double || rms is num) {
// 平滑处理RMS值,避免抖动太大,增大幅度
final double rawValue = rms.toDouble().clamp(0.0, 1.0);
// 增强幅度:平方根函数减少小值,对数函数增强大值
final double enhancedValue = (rawValue * 3.0).clamp(0.0, 1.0);
// 更强的平滑处理,减少抖动
audioAmplitude.value = audioAmplitude.value * 0.85 + enhancedValue * 0.15;
}
}
/// 处理错误
void _handleError(String error) {
Logger.error('Realtime错误: $error');
}
/// 切换字幕模式
void toggleSubtitleMode() {
isSubtitleMode.value = !isSubtitleMode.value;
}
/// 清空聊天记录
void clearChat() {
userSpeechText.value = '';
isUserSpeechFinal.value = true;
aiResponseMessages.clear();
latestUserMessage.value = '';
latestAiMessage.value = '';
partialUserMessage.value = '';
streamingAiMessage.value = '';
HapticFeedback.lightImpact();
}
/// 连接到AI服务
Future<void> connect() async {
if (isConnecting.value || isConnected.value) return;
try {
HapticFeedback.lightImpact();
// 初始化Realtime服务
bool initialized = await _realtimeService.initialize(
serverUrl: 'ws://192.168.1.11:8000/ws',
sampleRate: _sampleRate,
channels: 1,
bitsPerSample: 16,
);
if (!initialized) {
throw Exception('初始化Realtime服务失败');
}
// 连接服务器
final connected = await _realtimeService.connect();
if (!connected) {
throw Exception('连接Vocode服务器失败');
}
} catch (e) {
Logger.error('连接失败: $e');
isConnecting.value = false;
_resetState();
}
}
/// 断开连接
Future<void> disconnect() async {
try {
HapticFeedback.mediumImpact();
if (isListening.value) {
await _realtimeService.stopRecording();
}
await _realtimeService.stopPlaying();
await _realtimeService.disconnect();
} catch (e) {
Logger.error('断开连接失败: $e');
}
}
/// 开始聆听
void startListening() {
if (!isConnected.value || isListening.value) return;
HapticFeedback.lightImpact();
_realtimeService.startRecording().then((success) {
if (!success) {
Logger.error('开始录音失败');
}
});
}
/// 停止聆听
void stopListening() {
if (!isListening.value) return;
_realtimeService.stopRecording().then((success) {
if (!success) {
Logger.error('停止录音失败');
}
});
}
/// 切换聆听状态
void toggleListening() {
if (!isConnected.value) return;
if (isListening.value) {
stopListening();
} else {
startListening();
}
}
/// 关闭并返回上一页
Future<void> closeAndGoBack() async {
Get.back();
// 异步清理资源
Future.delayed(Duration.zero, () async {
try {
if (isListening.value) {
await _realtimeService.stopRecording();
}
await _realtimeService.stopPlaying();
await _realtimeService.disconnect();
await _realtimeService.dispose();
_resetState();
} catch (e) {
Logger.error('关闭连接失败: $e');
}
});
}
/// 重置状态
void _resetState() {
isConnected.value = false;
isConnecting.value = false;
isListening.value = false;
isSpeaking.value = false;
userSpeechText.value = '';
isUserSpeechFinal.value = true;
aiResponseMessages.clear();
latestUserMessage.value = '';
latestAiMessage.value = '';
partialUserMessage.value = '';
streamingAiMessage.value = '';
audioAmplitude.value = 0.0;
}
/// 清理资源
void _cleanup() async {
_eventSubscription?.cancel();
try {
await _realtimeService.dispose();
} catch (e) {
Logger.error('清理Realtime服务失败: $e');
}
}
}

332
apps/client/lib/modules/realtime/views/realtime_view.dart

@ -1,332 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/realtime_controller.dart';
import 'widgets/shader_mic_flow.dart';
class RealtimeView extends GetView<RealtimeController> {
const RealtimeView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// 设置状态栏为黑色文字
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
);
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Stack(
children: [
// 中间音频可视化
Positioned.fill(
child: Obx(() => AnimatedAlign(
alignment: controller.isSubtitleMode.value
? const Alignment(0, -0.7) // 字幕模式时移到更上方
: const Alignment(0, -0.25),
duration: const Duration(milliseconds: 600),
curve: Curves.easeInOutCubic,
child: LayoutBuilder(
builder: (context, constraints) {
final targetSize = controller.isSubtitleMode.value
? MediaQuery.of(context).size.width * 0.55 // 字幕模式时较小
: MediaQuery.of(context).size.width * 0.75; // 初始状态更大
return TweenAnimationBuilder<double>(
tween: Tween(end: targetSize),
duration: const Duration(milliseconds: 600),
curve: Curves.easeInOutCubic,
builder: (context, animatedSize, child) {
return Stack(
alignment: Alignment.center,
children: [
// 使用Shader动画
Obx(() => ShaderMicFlow(
size: animatedSize,
amplitude: controller.audioAmplitude.value,
)),
],
);
},
);
},
),
)),
),
// 字幕显示区域 - 只有在字幕模式开启时才显示,位于圆形下方
if (controller.isSubtitleMode.value)
Positioned(
left: 20,
right: 20,
top: MediaQuery.of(context).size.height * 0.35, // 在圆形下方
bottom: 160,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Obx(() => AnimatedOpacity(
opacity: controller.isSubtitleMode.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: TweenAnimationBuilder<double>(
tween: Tween(
begin: 0.0,
end: controller.isSubtitleMode.value ? 1.0 : 0.0,
),
duration: const Duration(milliseconds: 900), // 延迟显示,让圆圈先移动
curve: Curves.easeInOut,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - animationValue)), // 从下方滑入
child: Opacity(
opacity: animationValue,
child: _buildChatSubtitles(),
),
);
},
),
)),
),
),
// 底部控制按钮
Positioned(
bottom: 80,
left: 0,
right: 0,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 麦克风按钮
Obx(() => _buildMicButton()),
// 字幕按钮
Obx(() => _buildMainButton(
icon: controller.isSubtitleMode.value ? Icons.subtitles : Icons.subtitles_outlined,
onTap: controller.toggleSubtitleMode,
enabled: true,
isActive: controller.isSubtitleMode.value,
)),
// 清空聊天按钮
_buildMainButton(
icon: Icons.clear_all,
onTap: controller.clearChat,
enabled: true,
isActive: false,
),
// 关闭按钮
_buildMainButton(
icon: Icons.close,
onTap: controller.closeAndGoBack,
enabled: true,
isActive: false,
),
],
),
),
),
// Home Indicator
Positioned(
bottom: 8,
left: 0,
right: 0,
child: Center(
child: Container(
width: 134,
height: 5,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(2.5),
),
),
),
),
],
),
),
);
}
/// 构建聊天字幕界面 - 上下两行显示用户输入和AI回复
Widget _buildChatSubtitles() {
return Obx(() {
// 组合用户消息:优先显示实时部分转录,否则显示最终消息
final String displayUserMessage = controller.partialUserMessage.value.isNotEmpty
? controller.partialUserMessage.value
: controller.latestUserMessage.value;
// 组合AI消息:优先显示流式响应,否则显示最终消息
final String displayAiMessage = controller.streamingAiMessage.value.isNotEmpty
? controller.streamingAiMessage.value
: controller.latestAiMessage.value;
final hasUserMessage = displayUserMessage.isNotEmpty;
final hasAiMessage = displayAiMessage.isNotEmpty;
// 如果没有消息,显示空白
if (!hasUserMessage && !hasAiMessage) {
return Container();
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, // 让Column紧凑排列
children: [
// 用户输入行 - 蓝色,动态扩展
if (hasUserMessage)
Container(
width: double.infinity,
constraints: const BoxConstraints(
minHeight: 30, // 最小1行高度
maxHeight: 104, // 最大4行高度 (26*4)
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: SingleChildScrollView(
reverse: true,
child: Text(
displayUserMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
color: Colors.blue[600],
height: 1.4,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
),
),
),
),
// 分隔线 - 只有在两个消息都存在时才显示
if (hasUserMessage && hasAiMessage)
Container(
width: 60,
height: 1,
margin: const EdgeInsets.symmetric(vertical: 2), // 进一步减少分隔线间距
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(0.5),
),
),
// AI回复行 - 绿色,动态扩展
if (hasAiMessage)
Container(
width: double.infinity,
constraints: const BoxConstraints(
minHeight: 30, // 最小1行高度
maxHeight: 104, // 最大4行高度 (26*4)
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: SingleChildScrollView(
reverse: true,
child: Text(
displayAiMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
color: Colors.green[600],
height: 1.4,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
),
),
),
),
],
);
});
}
/// 构建麦克风按钮 - 根据连接状态和录音状态显示不同的状态
Widget _buildMicButton() {
// 获取当前状态
final isConnected = controller.isConnected.value;
final isConnecting = controller.isConnecting.value;
final isListening = controller.isListening.value;
// 确定按钮状态
IconData icon;
Color? backgroundColor;
Color? iconColor;
VoidCallback? onTap;
if (!isConnected && !isConnecting) {
// 未连接状态 - 禁用
icon = Icons.mic_off;
backgroundColor = Colors.grey[300];
iconColor = Colors.grey[600];
onTap = null;
} else if (isConnecting) {
// 连接中状态 - 禁用但显示连接中
icon = Icons.sync;
backgroundColor = Colors.orange[200];
iconColor = Colors.orange[700];
onTap = null;
} else if (isListening) {
// 录音中状态 - 激活状态
icon = Icons.mic;
backgroundColor = Theme.of(Get.context!).primaryColor;
iconColor = Colors.white;
onTap = controller.toggleListening;
} else {
// 已连接但未录音状态 - 正常状态
icon = Icons.mic_none;
backgroundColor = Colors.grey[200];
iconColor = Colors.black;
onTap = controller.toggleListening;
}
return GestureDetector(
onTap: onTap,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: Icon(
icon,
color: iconColor,
size: 26,
),
),
);
}
/// 构建主要控制按钮
Widget _buildMainButton({
required IconData icon,
VoidCallback? onTap,
required bool enabled,
required bool isActive,
}) {
return GestureDetector(
onTap: enabled ? onTap : null,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: enabled
? (isActive ? Theme.of(Get.context!).primaryColor : Colors.grey[200])
: Colors.grey[300],
shape: BoxShape.circle,
),
child: Icon(
icon,
color: enabled
? (isActive ? Colors.white : Colors.black)
: Colors.grey[600],
size: 26,
),
),
);
}
}

104
apps/client/lib/modules/realtime/views/widgets/shader_mic_flow.dart

@ -1,104 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// 使用Shader实现的麦克风流动动画组件
class ShaderMicFlow extends StatefulWidget {
final double size;
final double amplitude;
const ShaderMicFlow({
super.key,
required this.size,
this.amplitude = 0.0,
});
@override
State<ShaderMicFlow> createState() => _ShaderMicFlowState();
}
class _ShaderMicFlowState extends State<ShaderMicFlow>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
FragmentShader? _shader;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 60),
vsync: this,
)..repeat();
_loadShader();
}
Future<void> _loadShader() async {
try {
final program = await FragmentProgram.fromAsset('shaders/mic_flow.frag');
setState(() {
_shader = program.fragmentShader();
});
} catch (e) {
debugPrint('Failed to load shader: $e');
}
}
@override
void dispose() {
_controller.dispose();
_shader?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: _shader != null
? _ShaderPainter(
shader: _shader!,
time: _controller.value * 60.0,
amplitude: widget.amplitude,
)
: null,
size: Size.square(widget.size),
);
},
),
);
}
}
class _ShaderPainter extends CustomPainter {
final FragmentShader shader;
final double time;
final double amplitude;
_ShaderPainter({
required this.shader,
required this.time,
required this.amplitude,
});
@override
void paint(Canvas canvas, Size size) {
// 设置shader uniforms
shader.setFloat(0, size.width); // u_resolution.x
shader.setFloat(1, size.height); // u_resolution.y
shader.setFloat(2, time); // u_time
shader.setFloat(3, amplitude); // u_amp
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant _ShaderPainter oldDelegate) {
return oldDelegate.time != time || oldDelegate.amplitude != amplitude;
}
}

12
apps/client/lib/modules/speech_test/bindings/speech_test_binding.dart

@ -1,12 +0,0 @@
import 'package:get/get.dart';
import '../controllers/speech_test_controller.dart';
class SpeechTestBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<SpeechTestController>(
() => SpeechTestController(),
);
}
}

426
apps/client/lib/modules/speech_test/controllers/speech_test_controller.dart

@ -1,426 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/tts_service.dart';
import '../../../data/services/speech_factory.dart';
import '../../../core/utils/logger.dart';
/// 语音测试控制器
class SpeechTestController extends GetxController {
// 语音服务工厂
final SpeechFactory _speechFactory = Get.find<SpeechFactory>();
// 当前使用的TTS服务
late TtsService _ttsService;
// 当前使用的ASR服务
late AsrService _asrService;
// 状态变量
final RxBool isInitialized = false.obs;
final RxBool isProcessing = false.obs;
final RxBool isListening = false.obs;
final RxBool isSpeaking = false.obs;
// 识别结果
final RxString intermediateResult = ''.obs; // 中间结果
final RxString finalResult = ''.obs; // 最终结果
final RxString recognitionStatus = '准备就绪'.obs;
final RxString detectedLanguage = ''.obs; // 检测到的语言
// TTS事件
final RxString ttsStatus = '准备就绪'.obs;
// 当前选择的语音服务类型
final Rx<SpeechServiceType> selectedServiceType = SpeechServiceType.azure.obs;
// 音频输入源
bool _audioSourceType = false;
// 连续识别结果流
StreamSubscription<RecognitionEvent>? _recognitionSubscription;
// TTS事件流
StreamSubscription<TtsEvent>? _ttsEventSubscription;
// 文本控制器
final TextEditingController textController = TextEditingController();
// 连续播放的测试句子
final List<String> testSentences = [
'这是第一句测试语音。',
'这是第二句测试语音,语音合成支持连续播放。',
'这是第三句测试语音,将在连续模式下播放完毕。'
];
var _currsessionid = '';
@override
void onInit() {
super.onInit();
// 设置默认待播放文本
textController.text = '欢迎使用语音测试,这是一段默认的测试文本。您可以修改此文本,然后点击播放按钮测试语音合成效果。';
_initServices();
}
/// 初始化服务
Future<void> _initServices() async {
try {
isProcessing.value = true;
// 获取服务实例
_ttsService = _speechFactory.getTtsService();
_asrService = _speechFactory.getAsrService();
// 初始化服务
await _ttsService.initialize();
await _asrService.initialize();
// 监听TTS事件
_setupTtsEventListener();
// 获取识别事件流 - 这启动了异步识别过程
final stream = await _asrService.recognizeCallback();
// 设置事件监听 - 异步处理识别结果
_setupAsrEventListener(stream);
isInitialized.value = true;
ttsStatus.value = '准备就绪';
recognitionStatus.value = '准备就绪';
} catch (e) {
ttsStatus.value = '初始化失败';
recognitionStatus.value = '初始化失败';
Logger.error('语音测试初始化失败: ${e.toString()}');
} finally {
isProcessing.value = false;
}
}
/// 设置TTS事件监听
void _setupTtsEventListener() {
// 取消旧的订阅
_ttsEventSubscription?.cancel();
// 设置新的订阅
_ttsEventSubscription = _ttsService.onEvent.listen((event) {
switch (event.type) {
case TtsEventType.started:
isSpeaking.value = true;
ttsStatus.value = '正在播放...';
break;
case TtsEventType.completed:
isSpeaking.value = false;
ttsStatus.value = '播放完成';
break;
case TtsEventType.canceled:
isSpeaking.value = false;
ttsStatus.value = '播放已取消';
break;
case TtsEventType.error:
isSpeaking.value = false;
ttsStatus.value = '播放错误: ${event.error}';
break;
}
});
}
/// 设置ASR事件监听
void _setupAsrEventListener(Stream<RecognitionEvent> stream) {
// 取消旧的订阅
_recognitionSubscription?.cancel();
// 重置识别结果
intermediateResult.value = '';
finalResult.value = '';
detectedLanguage.value = '';
recognitionStatus.value = '正在等待语音输入...';
// 设置新的订阅
_recognitionSubscription = stream.listen((event) {
switch (event.type) {
case RecognitionEventType.sessionStarted:
isListening.value = true;
recognitionStatus.value = '开始聆听...';
break;
case RecognitionEventType.sessionStopped:
isListening.value = false;
recognitionStatus.value = '聆听已结束';
// 清空中间结果,保留最终结果
intermediateResult.value = '';
break;
case RecognitionEventType.intermediateResult:
// 更新中间结果,显示为斜体或不同颜色
intermediateResult.value = event.text;
if (event.detectedLanguage.isNotEmpty) {
detectedLanguage.value = event.detectedLanguage;
}
recognitionStatus.value = '正在识别...';
break;
case RecognitionEventType.finalResult:
// 更新最终结果,显示为正常文本
finalResult.value = event.text;
intermediateResult.value = ''; // 清空中间结果
if (event.detectedLanguage.isNotEmpty) {
detectedLanguage.value = event.detectedLanguage;
}
recognitionStatus.value = '识别完成';
break;
case RecognitionEventType.finalResult1:
// 更新最终结果,显示为正常文本
finalResult.value = event.text;
intermediateResult.value = ''; // 清空中间结果
if (event.detectedLanguage.isNotEmpty) {
detectedLanguage.value = event.detectedLanguage;
}
recognitionStatus.value = '识别完成';
break;
case RecognitionEventType.error:
isListening.value = false;
recognitionStatus.value = '识别错误: ${event.error}';
break;
case RecognitionEventType.canceled:
isListening.value = false;
recognitionStatus.value = '识别已取消';
// 清空中间结果
intermediateResult.value = '';
break;
}
}, onError: (error) {
isListening.value = false;
recognitionStatus.value = '识别错误: $error';
intermediateResult.value = '';
});
}
/// 切换语音服务类型
void switchServiceType(SpeechServiceType type) async {
if (selectedServiceType.value == type) return;
try {
isProcessing.value = true;
// 停止当前的所有服务状态
await stopAll();
// 切换服务类型
_speechFactory.setServiceType(type);
selectedServiceType.value = type;
// 重新初始化服务
await _initServices();
} catch (e) {
Logger.error('切换语音服务类型失败: ${e.toString()}');
} finally {
isProcessing.value = false;
}
}
/// 停止所有活动
Future<void> stopAll() async {
// 停止ASR
if (isListening.value) {
await stopContinuousRecognition();
}
// 停止TTS
if (isSpeaking.value) {
await stopSpeaking();
}
// 取消所有订阅
await _recognitionSubscription?.cancel();
_recognitionSubscription = null;
}
/// 单次播放文本
Future<void> speakOnce() async {
if (!isInitialized.value || isProcessing.value) return;
final text = textController.text.trim();
if (text.isEmpty) {
ttsStatus.value = '请输入要播放的文本';
return;
}
try {
isProcessing.value = true;
ttsStatus.value = '准备播放...';
final result = await _ttsService.speakOnce(_currsessionid, text);
if (!result) {
ttsStatus.value = '播放失败';
}
} catch (e) {
ttsStatus.value = '播放异常: $e';
Logger.error('单次播放异常: ${e.toString()}');
} finally {
isProcessing.value = false;
}
}
/// 连续播放测试
Future<void> speakContinuously() async {
if (!isInitialized.value || isProcessing.value) return;
try {
isProcessing.value = true;
ttsStatus.value = '准备连续播放...';
// 为避免阻塞,使用一个句子接一个句子的方式添加到队列
for (final sentence in testSentences) {
if (!await _ttsService.speakStream(_currsessionid, sentence)) {
ttsStatus.value = '添加句子到队列失败';
break;
}
// 给前一个句子一点处理时间
await Future.delayed(const Duration(milliseconds: 50));
}
// 刷新队列,确保播放完成
await _ttsService.flushStream(_currsessionid);
} catch (e) {
ttsStatus.value = '连续播放异常: $e';
Logger.error('连续播放异常: ${e.toString()}');
} finally {
isProcessing.value = false;
}
}
/// 停止播放
Future<void> stopSpeaking() async {
if (!isInitialized.value) return;
try {
await _ttsService.stop();
} catch (e) {
ttsStatus.value = '停止播放异常: $e';
Logger.error('停止播放异常: ${e.toString()}');
}
}
/// 一次性识别
Future<void> recognizeOnce() async {
if (!isInitialized.value || isProcessing.value || isListening.value) return;
try {
isProcessing.value = true;
recognitionStatus.value = '开始识别...';
// 清空之前的结果
intermediateResult.value = '';
finalResult.value = '';
detectedLanguage.value = '';
// 显示正在进行的状态
recognitionStatus.value = '正在聆听...';
// 执行一次性识别 - 这是同步等待结果的操作
final result = await _asrService.recognizeOnce();
if (result.isError) {
recognitionStatus.value = '识别错误: ${result.error}';
Logger.error('一次性识别错误: ${result.error}');
} else {
// 更新最终结果
finalResult.value = result.text;
// 更新语言信息
if (result.detectedLanguage.isNotEmpty) {
detectedLanguage.value = result.detectedLanguage;
Logger.debug('检测到语言: ${result.detectedLanguage}');
}
recognitionStatus.value = '识别完成';
}
} catch (e) {
recognitionStatus.value = '识别异常: $e';
Logger.error('一次性识别异常: ${e.toString()}');
} finally {
isProcessing.value = false;
}
}
/// 开始连续识别
Future<void> startContinuousRecognition() async {
if (!isInitialized.value || isProcessing.value || isListening.value) return;
try {
isProcessing.value = true;
// 清空之前的结果
intermediateResult.value = '';
finalResult.value = '';
detectedLanguage.value = '';
recognitionStatus.value = '准备连续识别...';
// 获取识别事件流 - 这启动了异步识别过程
final stream =
await _asrService.startContinuousRecognition(_audioSourceType);
} catch (e) {
recognitionStatus.value = '启动连续识别异常: $e';
Logger.error('启动连续识别异常: ${e.toString()}');
isListening.value = false;
intermediateResult.value = '';
finalResult.value = '';
} finally {
isProcessing.value = false;
}
}
/// 停止连续识别
Future<void> stopContinuousRecognition() async {
if (!isInitialized.value || !isListening.value) return;
try {
// 首先更新状态,防止重复调用
isListening.value = false;
recognitionStatus.value = '正在停止识别...';
// 停止识别过程
await _asrService.stopContinuousRecognition();
// 更新UI状态
recognitionStatus.value = '连续识别已停止';
intermediateResult.value = '';
} catch (e) {
recognitionStatus.value = '停止连续识别异常: $e';
Logger.error('停止连续识别异常: ${e.toString()}');
}
}
// 获取识别结果(用于UI显示)
String getDisplayResult() {
// 如果有最终结果,优先显示
String displayText = '';
if (finalResult.value.isNotEmpty) {
displayText = finalResult.value;
}
// 如果有中间结果,添加到最终结果后(可在UI中使用不同样式显示)
if (intermediateResult.value.isNotEmpty) {
if (displayText.isNotEmpty) {
displayText = '$displayText\n\n正在识别: ${intermediateResult.value}';
} else {
displayText = '正在识别: ${intermediateResult.value}';
}
}
return displayText;
}
@override
void onClose() {
// 取消所有订阅
_recognitionSubscription?.cancel();
_ttsEventSubscription?.cancel();
// 释放控制器
textController.dispose();
super.onClose();
}
}

359
apps/client/lib/modules/speech_test/views/speech_test_view.dart

@ -1,359 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/speech_test_controller.dart';
import '../../../data/services/speech_factory.dart';
class SpeechTestView extends GetView<SpeechTestController> {
const SpeechTestView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('语音测试'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => controller.switchServiceType(controller.selectedServiceType.value),
tooltip: '重新初始化',
),
],
),
body: Obx(() {
return controller.isInitialized.value
? _buildTestContent()
: _buildLoadingView();
}),
);
}
Widget _buildLoadingView() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('初始化语音服务中...'),
],
),
);
}
Widget _buildTestContent() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildServiceTypeSelector(),
const SizedBox(height: 24),
_buildTtsSection(),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
_buildAsrSection(),
],
),
);
}
Widget _buildServiceTypeSelector() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'选择语音服务',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Obx(() {
return Wrap(
spacing: 8,
children: [
for (final type in SpeechServiceType.values)
ChoiceChip(
label: Text(_getServiceTypeName(type)),
selected: controller.selectedServiceType.value == type,
onSelected: (selected) {
if (selected) {
controller.switchServiceType(type);
}
},
),
],
);
}),
],
),
),
);
}
String _getServiceTypeName(SpeechServiceType type) {
switch (type) {
case SpeechServiceType.azure:
return 'Azure语音';
case SpeechServiceType.iflytek:
return '讯飞语音';
case SpeechServiceType.google:
return 'Google语音';
case SpeechServiceType.alibaba:
return '阿里语音(旧版)';
case SpeechServiceType.alibaba35:
return '阿里语音3.5(声音复刻)';
case SpeechServiceType.volcano:
return '豆包语音';
case SpeechServiceType.flutter:
return 'Flutter本地';
}
}
Widget _buildTtsSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'语音合成测试',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextField(
controller: controller.textController,
decoration: const InputDecoration(
labelText: '输入要合成的文本',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 16),
Obx(() {
return Text(
'状态: ${controller.ttsStatus}',
style: TextStyle(
color: _getStatusColor(controller.ttsStatus.value),
fontWeight: FontWeight.bold,
),
);
}),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('单次播放'),
onPressed: controller.isProcessing.value ||
controller.isSpeaking.value
? null
: controller.speakOnce,
);
}),
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.playlist_play),
label: const Text('连续播放测试'),
onPressed: controller.isProcessing.value ||
controller.isSpeaking.value
? null
: controller.speakContinuously,
);
}),
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.stop),
label: const Text('停止'),
onPressed: controller.isSpeaking.value
? controller.stopSpeaking
: null,
);
}),
],
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
const Text(
'连续播放测试会依次播放以下句子:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
for (int i = 0; i < controller.testSentences.length; i++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('${i + 1}. ${controller.testSentences[i]}'),
),
],
),
),
);
}
Widget _buildAsrSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'语音识别测试',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Obx(() {
return Text(
'状态: ${controller.recognitionStatus}',
style: TextStyle(
color: _getStatusColor(controller.recognitionStatus.value),
fontWeight: FontWeight.bold,
),
);
}),
const SizedBox(height: 8),
Obx(() {
final hasLanguage = controller.detectedLanguage.value.isNotEmpty;
return Visibility(
visible: hasLanguage,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'检测语言: ${controller.detectedLanguage.value}',
style: const TextStyle(
fontStyle: FontStyle.italic,
color: Colors.blue,
),
),
),
);
}),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
constraints: const BoxConstraints(minHeight: 100),
width: double.infinity,
child: Obx(() {
final displayText = controller.getDisplayResult();
return RichText(
text: TextSpan(
style: const TextStyle(fontSize: 16, color: Colors.black),
children: [
if (displayText.isEmpty)
const TextSpan(
text: '识别结果将显示在这里',
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
)
else if (displayText.contains('正在识别:'))
TextSpan(
text: displayText.split('正在识别:')[0],
children: [
const TextSpan(
text: '\n\n正在识别: ',
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic,
),
),
TextSpan(
text: displayText.split('正在识别:')[1].trim(),
style: const TextStyle(
color: Colors.blue,
fontStyle: FontStyle.italic,
),
),
],
)
else
TextSpan(text: displayText),
],
),
);
}),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
Obx(() {
return ElevatedButton.icon(
icon: const Icon(Icons.mic),
label: const Text('一次性识别'),
onPressed: controller.isProcessing.value ||
controller.isListening.value ||
controller.isSpeaking.value
? null
: controller.recognizeOnce,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
),
);
}),
Obx(() {
return ElevatedButton.icon(
icon: Icon(
controller.isListening.value
? Icons.mic_off
: Icons.mic_none,
),
label: Text(
controller.isListening.value ? '停止连续识别' : '开始连续识别',
),
onPressed: controller.isProcessing.value ||
controller.isSpeaking.value
? null
: controller.isListening.value
? controller.stopContinuousRecognition
: controller.startContinuousRecognition,
style: ElevatedButton.styleFrom(
backgroundColor: controller.isListening.value
? Colors.red
: Colors.blue,
),
);
}),
],
),
],
),
),
);
}
Color _getStatusColor(String status) {
if (status.contains('错误') || status.contains('失败')) {
return Colors.red;
} else if (status.contains('完成') || status.contains('就绪')) {
return Colors.green;
} else if (status.contains('正在')) {
return Colors.blue;
} else {
return Colors.black;
}
}
}

9
apps/client/lib/modules/timbre/bindings/timbre_binding.dart

@ -1,9 +0,0 @@
import 'package:get/get.dart';
import '../controllers/timbre_controller.dart';
class TimbreBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<TimbreController>(() => TimbreController());
}
}

291
apps/client/lib/modules/timbre/controllers/timbre_controller.dart

@ -1,291 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
class TimbreController extends GetxController {
final GetStorage _storage = GetStorage();
final _record = AudioRecorder();
final String _appId = "43d87d22";
final String _appKey = "2bd01ce06ac35957fc0c082f9b6284ec";
RxList dataList = [].obs;
RxString type = '1'.obs;
RxString text = ''.obs;
String _token = '';
int _textId = 0;
int _segId = 0;
String _taskId = '';
@override
void onInit() {
super.onInit();
_getDataList();
}
@override
void onClose() {
_record.dispose();
super.onClose();
}
void _getDataList() {
List? timbreList = _storage.read('timbreList');
String? timbre = _storage.read('timbre');
if (timbreList != null) {
dataList.value = timbreList;
} else {
dataList.add({
'type': '1',
'name': 'defaultTimbre',
'description': 'microsoftCloudTimbreService',
'isUsable': true
});
_storage.write('timbreList', [
{
'type': '1',
'name': 'defaultTimbre',
'description': 'microsoftCloudTimbreService',
'isUsable': true
}
]);
}
if (timbre != null) {
type.value = timbre;
}
}
// 录制声音
void startRecorder() async {
if (_token.isEmpty) {
await _getToken();
}
await _getTraintext();
await _addTask();
final directory = await getApplicationDocumentsDirectory();
final recordDir = Directory('${directory.path}/RecordSound');
if (!await recordDir.exists()) {
await recordDir.create(recursive: true);
}
final path =
'${directory.path}/RecordSound/recording_${DateTime.now().millisecondsSinceEpoch}.wav';
await _record.start(
const RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 24000,
numChannels: 1,
),
path: path,
);
}
// 获取token
Future<void> _getToken() async {
int timeStamp = DateTime.now().millisecondsSinceEpoch;
String data =
'{"base":{"appid":"$_appId","version":"v1","timestamp":"$timeStamp"},"model":"remote"}';
String keySignInput = _appKey + timeStamp.toString();
List<int> keySignBytes = utf8.encode(keySignInput);
Digest keySignDigest = md5.convert(keySignBytes);
String keySign = keySignDigest.toString();
String signInput = keySign + data;
List<int> signBytes = utf8.encode(signInput);
Digest signDigest = md5.convert(signBytes);
String sign = signDigest.toString();
final response = await http.post(
Uri.parse('http://avatar-hci.xfyousheng.com/aiauth/v1/token'),
headers: {
'Content-Type': 'application/json',
'Authorization': sign,
},
body: data,
);
if (response.statusCode == 200) {
final body = json.decode(response.body);
if (body['retcode'] == '000000') {
_token = body['accesstoken'];
}
}
}
// 获取训练文本
Future<void> _getTraintext() async {
String data = '{"textId":"5001"}';
Map sign = _sign(data);
final response = await http.post(
Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/traintext'),
headers: {
'Content-Type': 'application/json',
'X-Sign': sign['sign'],
'X-Token': _token,
'X-AppId': _appId,
'X-Time': sign['timeStamp'],
},
body: data,
);
if (response.statusCode == 200) {
final body = json.decode(response.body);
if (body['code'] == 0) {
_textId = body['data']['textId'];
Map textSegs = body['data']['textSegs'].first;
_segId = textSegs['segId'];
text.value = _decodeCorruptedText(textSegs['segText']);
}
}
}
// 创建训练任务
Future<void> _addTask() async {
String data = '{"resourceType":"12","engineVersion":"omni_v1"}';
Map sign = _sign(data);
final response = await http.post(
Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/add'),
headers: {
'Content-Type': 'application/json',
'X-Sign': sign['sign'],
'X-Token': _token,
'X-AppId': _appId,
'X-Time': sign['timeStamp'],
},
body: data,
);
if (response.statusCode == 200) {
final body = json.decode(response.body);
if (body['code'] == 0) {
_taskId = body['data'];
}
}
}
Map _sign(String data) {
int timeStamp = DateTime.now().millisecondsSinceEpoch;
String keySignInput = _appKey + timeStamp.toString();
List<int> keySignBytes = utf8.encode(keySignInput);
Digest keySignDigest = md5.convert(keySignBytes);
String keySign = keySignDigest.toString();
String signInput = keySign + data;
List<int> signBytes = utf8.encode(signInput);
Digest signDigest = md5.convert(signBytes);
String sign = signDigest.toString();
return {'sign': sign, 'timeStamp': timeStamp.toString()};
}
String _decodeCorruptedText(String corruptedText) {
List<int> latin1Bytes =
corruptedText.codeUnits.map((ch) => ch & 0xFF).toList();
return utf8.decode(latin1Bytes);
}
// 完成录制
void completeRecorder() async {
final audioPath = await _record.stop();
String data =
'{"taskId":"$_taskId","textId":"$_textId","textSegId":"$_segId"}';
Map sign = _sign(data);
final uri = Uri.parse(
'http://opentrain.xfyousheng.com/voice_train/task/submitWithAudio');
final request = http.MultipartRequest('POST', uri);
request.files.add(await http.MultipartFile.fromPath(
'file',
audioPath!,
filename: path.basename(audioPath),
));
request.fields.addAll({
'taskId': _taskId,
'textId': '$_textId',
'textSegId': '$_segId',
});
request.headers.addAll({
"X-Sign": sign['sign'],
"X-Token": _token,
"X-AppId": _appId,
"X-Time": sign['timeStamp'],
});
final response = await request.send();
if (response.statusCode == 200) {
final responseBody = await response.stream.bytesToString();
final body = json.decode(responseBody);
if (body['code'] == 0) {
text.value = '';
dataList.add({
'type': _taskId,
'name': '声音复刻${dataList.length}',
'description': '复刻本人音色服务',
'isUsable': false
});
await _getTaskResult(_taskId);
}
}
}
// 查询训练任务状态
Future<void> _getTaskResult(String id) async {
String data = '{"taskId":"$id"}';
Map sign = _sign(data);
final response = await http.post(
Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/result'),
headers: {
'Content-Type': 'application/json',
'X-Sign': sign['sign'],
'X-Token': _token,
'X-AppId': _appId,
'X-Time': sign['timeStamp'],
},
body: data,
);
if (response.statusCode == 200) {
final body = json.decode(response.body);
if (body['code'] == 0) {
if (body['data']['trainStatus'] == -1) {
await _getTaskResult(id);
}
if (body['data']['trainStatus'] == 1) {
Map? dataMap =
dataList.firstWhereOrNull((item) => item['type'] == id);
if (dataMap != null) {
dataMap['type'] = body['data']['assetId'];
dataMap['isUsable'] = true;
dataList.refresh();
_storage.write('timbreList', dataList);
}
}
if (body['data']['trainStatus'] == 0) {
Get.snackbar(
'error'.tr, // 错误
_decodeCorruptedText(body['data']['failedDesc']),
snackPosition: SnackPosition.TOP,
);
}
}
}
}
// 取消
void cancel() {
text.value = '';
_record.stop();
Get.back();
}
// 选择音色
void selectTone(Map data) {
if (!data['isUsable'] || type.value == data['type']) return;
type.value = data['type'];
_storage.write('timbre', data['type']);
}
}

242
apps/client/lib/modules/timbre/views/timbre_view.dart

@ -1,242 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../../core/theme/app_colors.dart';
import '../controllers/timbre_controller.dart';
class TimbreView extends GetView<TimbreController> {
const TimbreView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
appBar: _buildAppBar(isDarkMode),
body: _buildBody(isDarkMode),
);
}
// 构建应用栏
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
return AppBar(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
elevation: 0.5,
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
onPressed: () => Get.back(),
),
title: Text(
'selecttimbre'.tr, // 选择音色
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
centerTitle: true,
);
}
// 构建主体内容
Widget _buildBody(bool isDarkMode) {
return Column(
children: [
Expanded(
child: Obx(
() => ListView.builder(
itemCount: controller.dataList.length,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w),
itemBuilder: (BuildContext context, int index) {
Map data = controller.dataList[index];
return _item(isDarkMode, data);
},
),
),
),
GestureDetector(
onTap: () {
controller.startRecorder();
Get.dialog(
barrierDismissible: false,
AlertDialog(
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
title: Text(
'pleaseRead'.tr, // 请大声朗读
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
),
),
content: Obx(
() => controller.text.value.isNotEmpty
? Text(
controller.text.value,
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
)
: Container(
width: 40.w,
height: 40.w,
alignment: Alignment.center,
child: CircularProgressIndicator(
strokeWidth: 2.w,
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.blue),
),
),
),
actions: [
TextButton(
onPressed: () {
controller.cancel();
},
child: Text(
'cancel'.tr, // 取消
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
),
Obx(() => controller.text.value.isNotEmpty
? TextButton(
onPressed: () {
Get.back();
controller.completeRecorder();
},
child: Text(
'completeRecording'.tr, // 完成录音
style: TextStyle(
color: isDarkMode
? Colors.red[300]
: Colors.red[600],
),
),
)
: const SizedBox()),
],
),
);
},
child: Container(
//width: 130.w,
height: 48.w,
margin: EdgeInsets.symmetric(vertical: 20.w),
padding: EdgeInsets.symmetric(horizontal: 14.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.primary : Colors.blue,
borderRadius: BorderRadius.circular(12.w),
),
child: Row(
mainAxisSize: MainAxisSize.min, // 添加这行使Row宽度自适应内容
//mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.keyboard_voice, color: Colors.white),
5.horizontalSpace,
Text(
'voiceClone'.tr,
style: TextStyle(color: Colors.white, fontSize: 16.sp),
),
],
),
),
),
],
);
}
Widget _item(bool isDarkMode, Map data) {
return InkWell(
onTap: () {
controller.selectTone(data);
},
child: Obx(
() => Container(
margin: EdgeInsets.symmetric(vertical: 6.h),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: controller.type.value == data['type']
? (isDarkMode
? Colors.teal[900]!.withOpacity(0.3)
: Colors.teal[50]!)
: (isDarkMode
? Colors.white.withOpacity(0.05)
: Colors.grey[100]!),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: controller.type.value == data['type']
? (isDarkMode ? Colors.teal[300]! : Colors.teal[600]!)
: Colors.transparent,
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${data['name']}'.tr,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4.h),
Text(
'${data['description']}'.tr,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
],
),
),
data['isUsable']
? Container(
width: 24.w,
height: 24.w,
margin: EdgeInsets.only(left: 12.w),
decoration: BoxDecoration(
color: controller.type.value == data['type']
? (isDarkMode ? Colors.teal[300] : Colors.teal[600])
: (isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.grey[300]),
shape: BoxShape.circle,
),
child: controller.type.value == data['type']
? Icon(
Icons.check,
size: 16.w,
color: Colors.white,
)
: null,
)
: SizedBox(
width: 24.w,
height: 24.w,
child: CircularProgressIndicator(
strokeWidth: 2.w,
valueColor:
const AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
],
),
),
),
);
}
}

23
apps/client/lib/modules/usage/bindings/usage_binding.dart

@ -1,23 +0,0 @@
import 'package:get/get.dart';
import '../controllers/usage_controller.dart';
import '../controllers/meeting_stats_controller.dart';
import '../controllers/translation_stats_controller.dart';
import '../controllers/aichat_stats_controller.dart';
import '../../../data/services/usage_stats_service.dart';
class UsageBinding implements Bindings {
@override
void dependencies() {
if (!Get.isRegistered<UsageStatsService>()) {
Get.put(UsageStatsService(), permanent: true);
}
Get.lazyPut<UsageController>(() => UsageController());
Get.lazyPut<AiChatStatsController>(() => AiChatStatsController());
Get.lazyPut<TranslationStatsController>(() => TranslationStatsController());
Get.lazyPut<MeetingStatsController>(() => MeetingStatsController());
}
}

10
apps/client/lib/modules/usage/controllers/aichat_stats_controller.dart

@ -1,10 +0,0 @@
import 'package:get/get.dart';
import '../../../data/models/user_Info.dart';
class AiChatStatsController extends GetxController {
final isLoading = false.obs;
int get currentIntegral => User.instance.aichatintegral;
int get totalIntegral => User.instance.aichattotalintegral;
}

226
apps/client/lib/modules/usage/controllers/meeting_stats_controller.dart

@ -1,226 +0,0 @@
import 'package:get/get.dart';
import '../../../data/services/usage_stats_service.dart';
import '../../../data/models/meeting_usage_stats.dart';
import '../../../core/utils/logger.dart';
class MeetingStatsController extends GetxController {
final UsageStatsService _usageService = Get.find<UsageStatsService>();
final isLoading = false.obs;
final selectedTabIndex = 0.obs;
final selectedTimeRange = 'today'.tr.obs; // 今日
final Rx<MeetingAsrHistoryStats> todayStats = MeetingAsrHistoryStats(
userId: '',
allAsrCalls: 0,
allAudioSeconds: 0,
allAudioFileBytes: 0,
allTranscriptBytes: 0,
).obs;
final Rx<MeetingAsrHistoryStats> historyStats = MeetingAsrHistoryStats(
userId: '',
allAsrCalls: 0,
allAudioSeconds: 0,
allAudioFileBytes: 0,
allTranscriptBytes: 0,
).obs;
final RxList<MeetingAsrDailyStats> historyDaily =
<MeetingAsrDailyStats>[].obs;
final Rx<MeetingStorageHistoryStats> todayStorageStats =
MeetingStorageHistoryStats(
userId: '',
allStorageUploads: 0,
allStorageBytes: 0,
allTransferBytes: 0,
).obs;
final Rx<MeetingStorageHistoryStats> historyStorageStats =
MeetingStorageHistoryStats(
userId: '',
allStorageUploads: 0,
allStorageBytes: 0,
allTransferBytes: 0,
).obs;
final Rx<DateTime?> rangeStart = Rx<DateTime?>(null);
final Rx<DateTime?> rangeEnd = Rx<DateTime?>(null);
final Rx<MeetingAsrHistoryStats> rangeStats = MeetingAsrHistoryStats(
userId: '',
allAsrCalls: 0,
allAudioSeconds: 0,
allAudioFileBytes: 0,
allTranscriptBytes: 0,
).obs;
final Rx<MeetingStorageHistoryStats> rangeStorageStats =
MeetingStorageHistoryStats(
userId: '',
allStorageUploads: 0,
allStorageBytes: 0,
allTransferBytes: 0,
).obs;
@override
void onInit() {
super.onInit();
loadAllStats();
}
Future<void> loadAllStats() async {
isLoading.value = true;
try {
final now = DateTime.now();
final todayList = await _usageService.getMeetingHistoryStats(
startDate: DateTime(now.year, now.month, now.day),
endDate: DateTime(now.year, now.month, now.day),
);
todayStats.value = _usageService.aggregateMeetingStats(todayList);
final historyStart = now.subtract(const Duration(days: 7));
historyDaily.value = await _usageService.getMeetingHistoryStats(
startDate:
DateTime(historyStart.year, historyStart.month, historyStart.day),
endDate: DateTime(now.year, now.month, now.day),
);
historyStats.value =
_usageService.aggregateMeetingStats(historyDaily.value);
final todayStorageDaily =
await _usageService.getMeetingStorageHistoryStats(
startDate: DateTime(now.year, now.month, now.day),
endDate: DateTime(now.year, now.month, now.day),
);
todayStorageStats.value =
_usageService.aggregateMeetingStorageStats(todayStorageDaily);
final historyStorageDaily =
await _usageService.getMeetingStorageHistoryStats(
startDate:
DateTime(historyStart.year, historyStart.month, historyStart.day),
endDate: DateTime(now.year, now.month, now.day),
);
historyStorageStats.value =
_usageService.aggregateMeetingStorageStats(historyStorageDaily);
if (rangeStart.value != null && rangeEnd.value != null) {
final customAsrDaily = await _usageService.getMeetingHistoryStats(
startDate: DateTime(rangeStart.value!.year, rangeStart.value!.month,
rangeStart.value!.day),
endDate: DateTime(
rangeEnd.value!.year, rangeEnd.value!.month, rangeEnd.value!.day),
);
rangeStats.value = _usageService.aggregateMeetingStats(customAsrDaily);
final customStorageDaily =
await _usageService.getMeetingStorageHistoryStats(
startDate: DateTime(rangeStart.value!.year, rangeStart.value!.month,
rangeStart.value!.day),
endDate: DateTime(
rangeEnd.value!.year, rangeEnd.value!.month, rangeEnd.value!.day),
);
rangeStorageStats.value =
_usageService.aggregateMeetingStorageStats(customStorageDaily);
} else {
rangeStats.value = historyStats.value;
rangeStorageStats.value = historyStorageStats.value;
}
Logger.info('会议统计数据加载完成');
} catch (e) {
Logger.error('加载会议统计数据失败: $e');
} finally {
isLoading.value = false;
}
}
void switchTimeRange(int index) {
selectedTabIndex.value = index;
switch (index) {
case 0:
selectedTimeRange.value = 'today'.tr; // 今日
break;
case 1:
selectedTimeRange.value = 'history'.tr; // 历史
break;
default:
selectedTimeRange.value = 'today'.tr; // 今日
break;
}
}
MeetingAsrHistoryStats get currentStats {
switch (selectedTabIndex.value) {
case 0:
return todayStats.value;
case 1:
return rangeStats.value;
default:
return todayStats.value;
}
}
String formatDuration(int seconds) {
if (seconds < 60) {
return '$seconds${'seconds'.tr}'; // 秒
} else if (seconds < 3600) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '$minutes${'minutes'.tr}$remainingSeconds${'seconds'.tr}'; // 分钟秒
} else {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
return '$hours${'hours'.tr}$minutes${'minutes'.tr}'; // 小时分钟
}
}
String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
final kb = bytes / 1024;
if (kb < 1024) return '${kb.toStringAsFixed(1)} KB';
final mb = kb / 1024;
if (mb < 1024) return '${mb.toStringAsFixed(1)} MB';
final gb = mb / 1024;
return '${gb.toStringAsFixed(1)} GB';
}
String formatNumber(int number) {
if (number < 1000) return number.toString();
if (number < 1000000) return '${(number / 1000).toStringAsFixed(1)}K';
return '${(number / 1000000).toStringAsFixed(1)}M';
}
Future<void> refreshData() async {
await loadAllStats();
}
Future<void> setCustomRangeDates(DateTime start, DateTime end) async {
rangeStart.value = start;
rangeEnd.value = end;
isLoading.value = true;
try {
final asrDaily = await _usageService.getMeetingHistoryStats(
startDate: DateTime(start.year, start.month, start.day),
endDate: DateTime(end.year, end.month, end.day),
);
rangeStats.value = _usageService.aggregateMeetingStats(asrDaily);
final storageDaily = await _usageService.getMeetingStorageHistoryStats(
startDate: DateTime(start.year, start.month, start.day),
endDate: DateTime(end.year, end.month, end.day),
);
rangeStorageStats.value =
_usageService.aggregateMeetingStorageStats(storageDaily);
} catch (e) {
Logger.error('设置自定义日期范围失败: $e');
} finally {
isLoading.value = false;
}
}
String formatDate(DateTime d) {
final y = d.year.toString();
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '$y-$m-$day';
}
}

120
apps/client/lib/modules/usage/controllers/translation_cost_controller.dart

@ -1,120 +0,0 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import '../../../data/services/usage_stats_service.dart';
import '../../../core/utils/logger.dart';
class TranslationCostController extends GetxController {
final UsageStatsService _usageService = Get.find<UsageStatsService>();
final RxMap<String, double> costConfig = <String, double>{}.obs;
final RxDouble dailyCostThreshold = 10.0.obs;
final RxDouble monthlyCostThreshold = 300.0.obs;
final translationCostController = TextEditingController();
final asrCostController = TextEditingController();
final ttsCostController = TextEditingController();
final aiSummaryCostController = TextEditingController();
final dailyThresholdController = TextEditingController();
final monthlyThresholdController = TextEditingController();
final isLoading = false.obs;
final isSaving = false.obs;
@override
void onInit() {
super.onInit();
}
@override
void onClose() {
translationCostController.dispose();
asrCostController.dispose();
ttsCostController.dispose();
aiSummaryCostController.dispose();
dailyThresholdController.dispose();
monthlyThresholdController.dispose();
super.onClose();
}
void _loadCurrentConfig() {}
Future<void> saveConfig() async {}
void resetToDefault() {}
bool _validateInputs() {
try {
final translationCost = double.parse(translationCostController.text);
final asrCost = double.parse(asrCostController.text);
final ttsCost = double.parse(ttsCostController.text);
final aiSummaryCost = double.parse(aiSummaryCostController.text);
final dailyThreshold = double.parse(dailyThresholdController.text);
final monthlyThreshold = double.parse(monthlyThresholdController.text);
if (translationCost < 0 ||
asrCost < 0 ||
ttsCost < 0 ||
aiSummaryCost < 0) {
_showErrorSnackbar('成本不能为负数');
return false;
}
if (dailyThreshold < 0 || monthlyThreshold < 0) {
_showErrorSnackbar('预警阈值不能为负数');
return false;
}
if (dailyThreshold > monthlyThreshold) {
_showErrorSnackbar('日预警阈值不能大于月预警阈值');
return false;
}
return true;
} catch (e) {
_showErrorSnackbar('请输入有效的数字');
return false;
}
}
double calculateEstimatedMonthlyCost() {
return 0.0;
}
List<String> getCostSavingTips() {
return [];
}
void _showSuccessSnackbar(String message) {
Get.snackbar(
'成功',
message,
snackPosition: SnackPosition.TOP,
backgroundColor: Get.theme.colorScheme.primary.withValues(alpha: 0.1),
colorText: Get.theme.colorScheme.primary,
duration: const Duration(seconds: 3),
);
}
void _showErrorSnackbar(String message) {
Get.snackbar(
'错误',
message,
snackPosition: SnackPosition.TOP,
backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.1),
colorText: Get.theme.colorScheme.error,
duration: const Duration(seconds: 3),
);
}
void _showInfoSnackbar(String message) {
Get.snackbar(
'提示',
message,
snackPosition: SnackPosition.TOP,
backgroundColor: Get.theme.colorScheme.surface.withValues(alpha: 0.9),
colorText: Get.theme.colorScheme.onSurface,
duration: const Duration(seconds: 3),
);
}
String formatCost(double cost) {
return '¥${cost.toStringAsFixed(2)}';
}
String formatPercentage(double percentage) {
return '${percentage.toStringAsFixed(1)}%';
}
}

328
apps/client/lib/modules/usage/controllers/translation_stats_controller.dart

@ -1,328 +0,0 @@
import 'package:get/get.dart';
import '../../../data/services/usage_stats_service.dart';
import '../../../data/models/translation_usage_stats.dart';
import '../../../core/utils/logger.dart';
import 'usage_controller.dart';
class TranslationStatsController extends GetxController {
final UsageStatsService _usageService = Get.find<UsageStatsService>();
final isLoading = false.obs;
final selectedTabIndex = 0.obs;
final selectedTimeRange = 'today'.tr.obs; // 今日
final Rx<TranslationUsageStats> todayStats =
TranslationUsageStats.empty('').obs;
final Rx<TranslationUsageStats> weekStats =
TranslationUsageStats.empty('').obs;
final Rx<TranslationUsageStats> monthStats =
TranslationUsageStats.empty('').obs;
final RxList<TranslationUsageStats> historyStats =
<TranslationUsageStats>[].obs;
RealtimeUsageStats get realtimeStats =>
_usageService.realtimeTranslationStats.value;
final RxMap<String, double> costConfig = <String, double>{}.obs;
final RxDouble dailyCostThreshold = 10.0.obs;
final RxDouble monthlyCostThreshold = 300.0.obs;
final RxList<Map<String, dynamic>> chartData = <Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
_initializeData();
}
void _initializeData() {
ever(_usageService.todayTranslationStats, (stats) {
todayStats.value = stats;
_updateChartData();
});
loadAllStats();
}
Future<void> loadAllStats() async {
isLoading.value = true;
try {
todayStats.value = _usageService.todayTranslationStats.value;
await _loadWeekStats();
await _loadMonthStats();
await _loadHistoryStats();
_updateChartData();
Logger.info('统计数据加载完成');
} catch (e) {
Logger.error('加载统计数据失败: $e');
Get.snackbar(
'loadFailed'.tr, // 加载失败
'statsLoadFailedTryLater'.tr, // 统计数据加载失败,请稍后重试
snackPosition: SnackPosition.TOP,
backgroundColor: Get.theme.colorScheme.error.withValues(alpha: 0.1),
colorText: Get.theme.colorScheme.error,
);
} finally {
isLoading.value = false;
}
}
Future<void> _loadWeekStats() async {
try {
final now = DateTime.now();
final weekStart = now.subtract(Duration(days: now.weekday - 1));
final weekEnd = weekStart.add(const Duration(days: 6));
final dailyStats = await _usageService.getHistoryStats(
startDate: weekStart,
endDate: weekEnd,
);
weekStats.value = _aggregateStats(dailyStats, 'week');
} catch (e) {
Logger.error('加载本周统计失败: $e');
}
}
Future<void> _loadMonthStats() async {
try {
final now = DateTime.now();
monthStats.value =
await _usageService.getMonthlyStats(now.year, now.month);
} catch (e) {
Logger.error('加载本月统计失败: $e');
}
}
Future<void> _loadHistoryStats() async {
try {
final endDate = DateTime.now();
final startDate = endDate.subtract(const Duration(days: 7));
historyStats.value = await _usageService.getHistoryStats(
startDate: startDate,
endDate: endDate,
);
historyStats.sort((a, b) => b.date.compareTo(a.date));
} catch (e) {
Logger.error('加载历史统计失败: $e');
}
}
TranslationUsageStats _aggregateStats(
List<TranslationUsageStats> statsList, String period) {
if (statsList.isEmpty) {
return TranslationUsageStats.empty(_usageService.getCurrentUserId());
}
int totalTranslationCalls = 0;
int totalAsrCalls = 0;
int totalTtsCalls = 0;
int totalAiSummaryCalls = 0;
int totalSourceChars = 0;
int totalTargetChars = 0;
int totalRecordingDuration = 0;
int totalTtsPlayDuration = 0;
int totalSessions = 0;
final Map<String, int> combinedLanguagePairUsage = {};
final Map<String, int> combinedModeUsage = {};
for (final stats in statsList) {
totalTranslationCalls += stats.translationApiCalls;
totalAsrCalls += stats.asrApiCalls;
totalTtsCalls += stats.ttsApiCalls;
totalAiSummaryCalls += stats.aiSummaryApiCalls;
totalSourceChars += stats.sourceTextCharacters;
totalTargetChars += stats.ttsTextCharacters;
totalRecordingDuration += stats.effectiveAudioDuration;
totalTtsPlayDuration += stats.totalSessionDuration;
totalSessions += stats.sessionCount;
stats.languagePairUsage.forEach((key, value) {
combinedLanguagePairUsage[key] =
(combinedLanguagePairUsage[key] ?? 0) + value;
});
stats.modeUsage.forEach((key, value) {
combinedModeUsage[key] = (combinedModeUsage[key] ?? 0) + value;
});
}
return TranslationUsageStats(
id: '${period}_${DateTime.now().millisecondsSinceEpoch}',
date: DateTime.now(),
userId: 'current_user',
timeGranularity: period,
translationApiCalls: totalTranslationCalls,
asrApiCalls: totalAsrCalls,
ttsApiCalls: totalTtsCalls,
aiSummaryApiCalls: totalAiSummaryCalls,
sourceTextCharacters: totalSourceChars,
ttsTextCharacters: totalTargetChars,
effectiveAudioDuration: totalRecordingDuration,
totalSessionDuration: totalTtsPlayDuration,
sessionCount: totalSessions,
estimatedCost: 0.0,
);
}
void _updateChartData() {
final List<Map<String, dynamic>> data = [];
switch (selectedTabIndex.value) {
case 0:
data.add({
'label': 'today'.tr, // 今日
'apiCalls': todayStats.value.totalApiCalls,
'cost': 0.0,
'characters': todayStats.value.totalCharacterCount,
});
break;
case 1:
data.add({
'label': 'thisWeek'.tr, // 本周
'apiCalls': weekStats.value.totalApiCalls,
'cost': 0.0,
'characters': weekStats.value.totalCharacterCount,
});
break;
case 2:
data.add({
'label': 'thisMonth'.tr, // 本月
'apiCalls': monthStats.value.totalApiCalls,
'cost': 0.0,
'characters': monthStats.value.totalCharacterCount,
});
break;
case 3:
for (final stats in historyStats.take(7)) {
data.add({
'label': '${stats.date.month}/${stats.date.day}',
'apiCalls': stats.totalApiCalls,
'cost': 0.0,
'characters': stats.totalCharacterCount,
});
}
break;
}
chartData.value = data;
}
void switchTimeRange(int index) {
selectedTabIndex.value = index;
switch (index) {
case 0:
selectedTimeRange.value = 'today'.tr; // 今日
break;
case 1:
selectedTimeRange.value = 'thisWeek'.tr; // 本周
break;
case 2:
selectedTimeRange.value = 'thisMonth'.tr; // 本月
break;
case 3:
selectedTimeRange.value = 'history'.tr; // 历史
break;
}
_updateChartData();
}
TranslationUsageStats get currentStats {
switch (selectedTabIndex.value) {
case 0:
return todayStats.value;
case 1:
return weekStats.value;
case 2:
return monthStats.value;
default:
return todayStats.value;
}
}
Future<void> refreshData() async {
await loadAllStats();
}
void cleanupOldData() {
_usageService.cleanupOldData();
Get.snackbar(
'cleanupComplete'.tr, // 清理完成
'cleanedOldData'.tr, // 已清理90天前的过期数据
snackPosition: SnackPosition.TOP,
backgroundColor: Get.theme.colorScheme.primary.withValues(alpha: 0.1),
colorText: Get.theme.colorScheme.primary,
);
}
Future<void> exportStats() async {
try {
Get.snackbar(
'exportFeature'.tr, // 导出功能
'exportInDevelopment'.tr, // 数据导出功能正在开发中
snackPosition: SnackPosition.TOP,
backgroundColor: Get.theme.colorScheme.primary.withValues(alpha: 0.1),
colorText: Get.theme.colorScheme.primary,
);
} catch (e) {
Logger.error('导出统计数据失败: $e');
}
}
String formatDuration(int seconds) {
if (seconds < 60) {
return '$seconds${'seconds'.tr}'; // 秒
} else if (seconds < 3600) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '$minutes${'minutes'.tr}$remainingSeconds${'seconds'.tr}'; // 分钟秒
} else {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
return '$hours${'hours'.tr}$minutes${'minutes'.tr}'; // 小时分钟
}
}
String formatCost(double cost) {
return '¥${cost.toStringAsFixed(2)}';
}
int _titleTapCount = 0;
int _lastTitleTapTs = 0;
void onTitleTap() {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastTitleTapTs <= 800) {
_titleTapCount++;
} else {
_titleTapCount = 1;
}
_lastTitleTapTs = now;
if (_titleTapCount >= 3) {
_titleTapCount = 0;
final uc = Get.find<UsageController>();
uc.toggleAiChat(!uc.showAiChat.value);
}
}
String formatNumber(int number) {
if (number < 1000) {
return number.toString();
} else if (number < 1000000) {
return '${(number / 1000).toStringAsFixed(1)}K';
} else {
return '${(number / 1000000).toStringAsFixed(1)}M';
}
}
}

41
apps/client/lib/modules/usage/controllers/usage_controller.dart

@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../views/translation_stats_view.dart';
import '../views/meeting_stats_view.dart';
import '../views/aichat_stats_view.dart';
class UsageController extends GetxController {
late PageController pageController;
final RxInt pageIndex = 0.obs;
final RxBool showAiChat = false.obs;
List<Widget> get pageList => showAiChat.value
? const [
AiChatStatsView(),
TranslationStatsView(),
MeetingStatsView(),
]
: const [
TranslationStatsView(),
MeetingStatsView(),
];
@override
void onInit() {
super.onInit();
pageController = PageController(initialPage: 0);
}
void toggleAiChat(bool enable) {
if (showAiChat.value == enable) return;
showAiChat.value = enable;
final int maxIndex = pageList.length - 1;
if (pageIndex.value > maxIndex) {
pageIndex.value = maxIndex;
pageController.jumpToPage(maxIndex);
} else {
pageController.jumpToPage(pageIndex.value);
}
}
}

93
apps/client/lib/modules/usage/views/aichat_stats_view.dart

@ -1,93 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../../core/theme/app_colors.dart';
import '../controllers/aichat_stats_controller.dart';
import '../../../data/models/user_Info.dart';
class AiChatStatsView extends GetView<AiChatStatsController> {
const AiChatStatsView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50],
appBar: _buildAppBar(isDarkMode),
body: _buildBody(isDarkMode),
);
}
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
return AppBar(
leading: IconButton(
icon: Icon(Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black, size: 20.sp),
onPressed: () => Get.back(),
),
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white,
elevation: 0.5,
title: Text(
'aiChat'.tr, // AI聊天
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
centerTitle: true,
);
}
Widget _buildBody(bool isDarkMode) {
return Column(
children: [_buildIntegralRow(isDarkMode)],
);
}
Widget _buildIntegralRow(bool isDarkMode) {
return Obx(() {
final _ = User.refreshTick.value;
final u = User.instance;
return Padding(
padding: EdgeInsets.all(16.w),
child: Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2)),
],
),
child: Row(
children: [
Icon(Icons.chat_bubble_outline,
color: AppColors.primary, size: 20.sp),
SizedBox(width: 8.w),
Expanded(
child: Text(
'remainingQuota'.tr,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
),
Text(
'${u.aichatintegral}/${u.aichattotalintegral}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
],
),
),
);
});
}
}

266
apps/client/lib/modules/usage/views/meeting_stats_view.dart

@ -1,266 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../../core/theme/app_colors.dart';
import '../controllers/meeting_stats_controller.dart';
import '../../../data/models/user_Info.dart';
import '../../../data/models/meeting_usage_stats.dart';
class MeetingStatsView extends GetView<MeetingStatsController> {
const MeetingStatsView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50],
appBar: _buildAppBar(context, isDarkMode),
body: Obx(() => controller.isLoading.value
? const Center(child: CircularProgressIndicator())
: _buildBody(isDarkMode)),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) {
return AppBar(
leading: IconButton(
icon: Icon(Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black, size: 20.sp),
onPressed: () => Get.back(),
),
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white,
elevation: 0.5,
title: Text(
'meetingStats'.tr, // 会议统计
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
centerTitle: true,
actions: [
IconButton(
icon: Icon(Icons.date_range,
color: isDarkMode ? Colors.white : Colors.black),
onPressed: () async {
final now = DateTime.now();
final initialStart = controller.rangeStart.value ??
DateTime(now.year, now.month, now.day);
final initialEnd = controller.rangeEnd.value ??
DateTime(now.year, now.month, now.day);
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 1),
initialDateRange:
DateTimeRange(start: initialStart, end: initialEnd),
);
if (picked != null) {
await controller.setCustomRangeDates(picked.start, picked.end);
}
},
),
IconButton(
icon: Icon(Icons.refresh,
color: isDarkMode ? Colors.white : Colors.black),
onPressed: controller.refreshData,
),
],
);
}
Widget _buildBody(bool isDarkMode) {
final hasRange = controller.rangeStart.value != null &&
controller.rangeEnd.value != null;
final historyTitle = hasRange
? "${'history'.tr} (${controller.formatDate(controller.rangeStart.value!)} ~ ${controller.formatDate(controller.rangeEnd.value!)})"
: 'history'.tr;
return Column(
children: [
_buildIntegralRow(isDarkMode),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
_buildOverviewCard(
'today'.tr, // 今日
isDarkMode,
controller.todayStats.value,
controller.todayStorageStats.value,
),
SizedBox(height: 16.h),
_buildOverviewCard(
historyTitle,
isDarkMode,
controller.rangeStats.value,
controller.rangeStorageStats.value,
),
],
),
),
),
],
);
}
Widget _buildIntegralRow(bool isDarkMode) {
return Obx(() {
final _ = User.refreshTick.value;
final u = User.instance;
return Padding(
padding: EdgeInsets.all(16.w),
child: Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2)),
],
),
child: Row(
children: [
Icon(Icons.people, color: AppColors.primary, size: 20.sp),
SizedBox(width: 8.w),
Expanded(
child: Text(
'remainingQuota'.tr,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
),
Text(
'${u.meetintegral}/${u.meettotalintegral}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
],
),
),
);
});
}
Widget _buildOverviewCard(
String title,
bool isDarkMode,
MeetingAsrHistoryStats asrStats,
MeetingStorageHistoryStats storageStats,
) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.dashboard, color: AppColors.primary, size: 20.sp),
SizedBox(width: 8.w),
Text(
'$title${'overview'.tr}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
],
),
SizedBox(height: 16.h),
Row(
children: [
Expanded(
child: _buildStatItem(
'asrApiCalls'.tr, // ASR调用次数
controller.formatNumber(asrStats.allAsrCalls),
Icons.api,
isDarkMode)),
Expanded(
child: _buildStatItem(
'totalAudioDuration'.tr, // 音频总时长
controller.formatDuration(asrStats.allAudioSeconds),
Icons.mic,
isDarkMode)),
],
),
SizedBox(height: 12.h),
Row(
children: [
Expanded(
child: _buildStatItem(
'audioFileSize'.tr, // 音频文件大小
controller.formatBytes(asrStats.allAudioFileBytes),
Icons.folder,
isDarkMode)),
Expanded(
child: _buildStatItem(
'transcriptBytes'.tr, // 转写字节
controller.formatNumber(asrStats.allTranscriptBytes),
Icons.text_snippet,
isDarkMode)),
],
),
SizedBox(height: 12.h),
Row(
children: [
Expanded(
child: _buildStatItem(
'storageUploads'.tr, // 对象存储上传次数
controller.formatNumber(storageStats.allStorageUploads),
Icons.cloud_upload,
isDarkMode)),
Expanded(
child: _buildStatItem(
'storageBytes'.tr, // 对象存储总大小
controller.formatBytes(storageStats.allStorageBytes),
Icons.cloud,
isDarkMode)),
],
),
],
),
);
}
Widget _buildStatItem(
String label, String value, IconData icon, bool isDarkMode) {
return Column(
children: [
Icon(icon, color: AppColors.primary, size: 24.sp),
SizedBox(height: 8.h),
Text(
value,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black),
),
SizedBox(height: 4.h),
Text(
label,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54),
),
],
);
}
}

807
apps/client/lib/modules/usage/views/translation_stats_view.dart

@ -1,807 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/translation_stats_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../data/models/user_Info.dart';
class TranslationStatsView extends GetView<TranslationStatsController> {
const TranslationStatsView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50],
appBar: _buildAppBar(isDarkMode),
body: Obx(() => controller.isLoading.value
? _buildLoadingWidget()
: _buildBody(isDarkMode)),
);
}
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
return AppBar(
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
onPressed: () => Get.back(),
),
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white,
elevation: 0.5,
title: GestureDetector(
onTap: controller.onTitleTap,
child: Text(
'translationStats'.tr, //
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
centerTitle: true,
actions: [
IconButton(
icon: Icon(
Icons.refresh,
color: isDarkMode ? Colors.white : Colors.black,
),
onPressed: controller.refreshData,
),
// PopupMenuButton<String>(
// icon: Icon(
// Icons.more_vert,
// color: isDarkMode ? Colors.white : Colors.black,
// ),
// onSelected: (value) {
// switch (value) {
// case 'cleanup':
// controller.cleanupOldData();
// break;
// // case 'export':
// // controller.exportStats();
// // break;
// }
// },
// itemBuilder: (context) => const [
// PopupMenuItem(
// value: 'cleanup',
// child: Text('清理过期数据'),
// ),
// // PopupMenuItem(
// // value: 'export',
// // child: Text('导出数据'),
// // ),
// ],
// ),
],
);
}
Widget _buildLoadingWidget() {
return const Center(
child: CircularProgressIndicator(),
);
}
Widget _buildBody(bool isDarkMode) {
return Column(
children: [
_buildIntegralRow(isDarkMode),
_buildTimeRangeSelector(isDarkMode),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
// _buildIntegralCard(isDarkMode),
// SizedBox(height: 16.h),
if (controller.selectedTabIndex.value == 0)
_buildRealtimeStatsCard(isDarkMode),
SizedBox(height: 16.h),
_buildOverviewCard(isDarkMode),
SizedBox(height: 16.h),
_buildApiCallsCard(isDarkMode),
SizedBox(height: 16.h),
_buildDataVolumeCard(isDarkMode),
SizedBox(height: 16.h),
_buildUsageBehaviorCard(isDarkMode),
],
),
),
),
],
);
}
Widget _buildIntegralRow(bool isDarkMode) {
return Obx(() {
final _ = User.refreshTick.value;
final u = User.instance;
return Padding(
padding: EdgeInsets.all(16.w),
child: Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2)),
],
),
child: Row(
children: [
Icon(Icons.translate, color: AppColors.primary, size: 20.sp),
SizedBox(width: 8.w),
Expanded(
child: Text(
'remainingQuota'.tr,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
),
Text(
'${u.tradeintegral}/${u.tradetotalintegral}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black),
),
],
),
),
);
});
}
Widget _buildTimeRangeSelector(bool isDarkMode) {
return Container(
margin: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Obx(() => Row(
children: [
_buildTimeRangeTab('today'.tr, 0, isDarkMode), // 今日
_buildTimeRangeTab('thisWeek'.tr, 1, isDarkMode), // 本周
_buildTimeRangeTab('thisMonth'.tr, 2, isDarkMode), // 本月
_buildTimeRangeTab('history'.tr, 3, isDarkMode), // 历史
],
)),
);
}
Widget _buildTimeRangeTab(String title, int index, bool isDarkMode) {
final isSelected = controller.selectedTabIndex.value == index;
return Expanded(
child: GestureDetector(
onTap: () => controller.switchTimeRange(index),
child: Container(
padding: EdgeInsets.symmetric(vertical: 12.h),
decoration: BoxDecoration(
color: isSelected
? (isDarkMode ? AppColors.primary : AppColors.primary)
: Colors.transparent,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? Colors.white
: (isDarkMode ? Colors.white70 : Colors.black54),
),
),
),
),
);
}
Widget _buildRealtimeStatsCard(bool isDarkMode) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.access_time,
color: AppColors.primary,
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
'currentSessionStats'.tr, // 当前会话统计
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
),
SizedBox(height: 16.h),
Obx(() => Row(
children: [
Expanded(
child: _buildStatItem(
'translationCount'.tr, // 翻译次数
controller.realtimeStats.translationCount.toString(),
Icons.translate,
isDarkMode,
),
),
Expanded(
child: _buildStatItem(
'sessionDuration'.tr, // 会话时长
'${controller.realtimeStats.sessionDurationMinutes}${'minutes'.tr}',
Icons.timer,
isDarkMode,
),
),
],
)),
SizedBox(height: 12.h),
Obx(() => Row(
children: [
Expanded(
child: _buildStatItem(
'characters'.tr, // 字符数
controller.formatNumber(
controller.realtimeStats.sourceCharacters +
controller.realtimeStats.targetCharacters,
),
Icons.text_fields,
isDarkMode,
),
),
Expanded(
child: _buildStatItem(
'recordingDuration'.tr, // 录音时长
controller.formatDuration(
controller.realtimeStats.recordingDuration),
Icons.mic,
isDarkMode,
),
),
],
)),
],
),
);
}
Widget _buildOverviewCard(bool isDarkMode) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.dashboard,
color: AppColors.primary,
size: 20.sp,
),
SizedBox(width: 8.w),
Obx(() => Text(
'${controller.selectedTimeRange.value}${'overview'.tr}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
)),
],
),
SizedBox(height: 16.h),
Obx(() => Row(
children: [
Expanded(
child: _buildStatItem(
'totalApiCalls'.tr, // 总调用次数
controller
.formatNumber(controller.currentStats.totalApiCalls),
Icons.api,
isDarkMode,
),
),
],
)),
SizedBox(height: 12.h),
Obx(() => Row(
children: [
Expanded(
child: _buildStatItem(
'sessionCount'.tr, // 会话数量
controller.currentStats.sessionCount.toString(),
Icons.chat,
isDarkMode,
),
),
Expanded(
child: _buildStatItem(
'totalCharacters'.tr, // 总字符数
controller.formatNumber(
controller.currentStats.totalCharacterCount),
Icons.text_snippet,
isDarkMode,
),
),
],
)),
],
),
);
}
Widget _buildStatItem(
String label, String value, IconData icon, bool isDarkMode) {
return Column(
children: [
Icon(
icon,
color: AppColors.primary,
size: 24.sp,
),
SizedBox(height: 8.h),
Text(
value,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black,
),
),
SizedBox(height: 4.h),
Text(
label,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
],
);
}
Widget _buildApiCallsCard(bool isDarkMode) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.api,
color: AppColors.primary,
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
'apiCallStats'.tr, // API调用统计
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
),
SizedBox(height: 16.h),
Obx(() => Column(
children: [
_buildApiCallRow(
'translationApi'.tr, // 翻译API
controller.currentStats.translationApiCalls,
Icons.translate,
isDarkMode),
SizedBox(height: 12.h),
_buildApiCallRow(
'asr'.tr,
controller.currentStats.asrApiCalls,
Icons.mic,
isDarkMode),
SizedBox(height: 12.h),
_buildApiCallRow(
'tts'.tr,
controller.currentStats.ttsApiCalls,
Icons.volume_up,
isDarkMode),
SizedBox(height: 12.h),
_buildApiCallRow(
'aiSummary'.tr, // AI总结
controller.currentStats.aiSummaryApiCalls,
Icons.summarize,
isDarkMode),
],
)),
],
),
);
}
Widget _buildApiCallRow(
String label, int count, IconData icon, bool isDarkMode) {
return Row(
children: [
Icon(
icon,
color: AppColors.primary,
size: 18.sp,
),
SizedBox(width: 12.w),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Text(
controller.formatNumber(count),
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
);
}
Widget _buildDataVolumeCard(bool isDarkMode) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.data_usage,
color: AppColors.primary,
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
'dataVolumeStats'.tr, // 数据量统计
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
),
SizedBox(height: 16.h),
Obx(() => Column(
children: [
Row(
children: [
Expanded(
child: _buildStatItem(
'sourceTextCharacters'.tr, // 源文本字符
controller.formatNumber(
controller.currentStats.sourceTextCharacters),
Icons.text_fields,
isDarkMode,
),
),
Expanded(
child: _buildStatItem(
'ttsTextCharacters'.tr, // TTS文本字符
controller.formatNumber(
controller.currentStats.ttsTextCharacters),
Icons.translate,
isDarkMode,
),
),
],
),
SizedBox(height: 16.h),
Row(
children: [
Expanded(
child: _buildStatItem(
'effectiveAudioDuration'.tr, // 有效音频时长
controller.formatDuration(
controller.currentStats.effectiveAudioDuration),
Icons.mic,
isDarkMode,
),
),
Expanded(
child: _buildStatItem(
'totalSessionDuration'.tr, // 总会话时长
controller.formatDuration(
controller.currentStats.totalSessionDuration),
Icons.volume_up,
isDarkMode,
),
),
],
),
],
)),
],
),
);
}
Widget _buildCostCard(bool isDarkMode) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.attach_money,
color: AppColors.primary,
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
'costStats'.tr, // 成本统计
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
),
SizedBox(height: 16.h),
Obx(() => Column(
children: [
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${controller.selectedTimeRange.value}${'estimatedCost'.tr}',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black,
),
),
Text(
controller.formatCost(
controller.currentStats.estimatedCost),
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
],
),
),
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'dailyCostThreshold'.tr, // 日成本预警阈值
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
Text(
controller
.formatCost(controller.dailyCostThreshold.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
],
),
SizedBox(height: 4.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'monthlyCostThreshold'.tr, // 月成本预警阈值
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
Text(
controller
.formatCost(controller.monthlyCostThreshold.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
],
),
],
)),
],
),
);
}
Widget _buildUsageBehaviorCard(bool isDarkMode) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.analytics,
color: AppColors.primary,
size: 20.sp,
),
SizedBox(width: 8.w),
Text(
'usageBehaviorStats'.tr, // 使用行为统计
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
),
SizedBox(height: 16.h),
Obx(() => Column(
children: [
_buildUsageBehaviorRow(
'mostUsedLanguagePair'.tr, // 最常用语言对
controller.currentStats.mostUsedLanguagePair,
Icons.language,
isDarkMode,
),
SizedBox(height: 12.h),
_buildUsageBehaviorRow(
'mostUsedMode'.tr, // 最常用模式
controller.currentStats.mostUsedMode,
Icons.settings,
isDarkMode,
),
],
)),
],
),
);
}
Widget _buildUsageBehaviorRow(
String label, String value, IconData icon, bool isDarkMode) {
return Row(
children: [
Icon(
icon,
color: AppColors.primary,
size: 18.sp,
),
SizedBox(width: 12.w),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
Text(
value,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
);
}
}

104
apps/client/lib/modules/usage/views/usage_view.dart

@ -1,104 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../../core/theme/app_colors.dart';
import '../controllers/usage_controller.dart';
class UsageView extends GetView<UsageController> {
const UsageView({super.key});
// 2. 颜色配置集中管理
Color _getBgColor(bool isDark) =>
isDark ? Colors.black : const Color(0xFFF9FAFB);
Color _getUnselectedColor(bool isDark) =>
isDark ? Colors.grey[500]! : const Color(0xFF6B7280);
Color _getSelectedColor(bool isDark) =>
isDark ? Colors.blue[200]! : const Color(0xFF3B82F6);
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50],
body: Obx(() => PageView(
physics: const NeverScrollableScrollPhysics(),
controller: controller.pageController,
children: controller.pageList,
)),
bottomNavigationBar: Obx(
() {
final currentIndex = controller.pageIndex.value;
return Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: isDarkMode ? Colors.grey[800]! : Colors.grey[200]!,
width: 0.5,
)),
),
// 3. 添加安全区域适配(针对刘海屏)
child: BottomNavigationBar(
// 4. 使用生成的导航项
items: (controller.showAiChat.value
? const [
_NavItem(Icons.chat_bubble_outline, 'aiChat'),
_NavItem(Icons.translate, 'interpretation'),
_NavItem(Icons.people, 'meeting'),
]
: const [
_NavItem(Icons.translate, 'interpretation'),
_NavItem(Icons.people, 'meeting'),
])
.map((item) => BottomNavigationBarItem(
icon: Icon(item.icon),
activeIcon: Icon(item.icon,
color: _getSelectedColor(isDarkMode)), // 5. 激活态图标
label: item.label.tr, // 统一处理国际化
))
.toList(),
backgroundColor: _getBgColor(isDarkMode),
currentIndex: currentIndex,
unselectedItemColor: _getUnselectedColor(isDarkMode),
selectedItemColor: _getSelectedColor(isDarkMode),
unselectedFontSize: 10.sp,
selectedFontSize: 10.sp,
// 6. 添加动画过渡效果
onTap: (index) => _onItemTapped(index, currentIndex),
type: BottomNavigationBarType.fixed,
// 7. 增加图标和文字的间距
iconSize: 22.w, // 响应式图标尺寸
selectedLabelStyle: TextStyle(height: 1.5.sp), // 文字垂直间距
unselectedLabelStyle: TextStyle(height: 1.5.sp),
),
);
},
),
);
}
// 9. 抽离点击逻辑
void _onItemTapped(int index, int currentIndex) {
if (index == currentIndex) return;
// 10. 添加页面切换动画
controller.pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
controller.pageIndex.value = index;
}
}
// 11. 使用数据类管理导航项配置
class _NavItem {
final IconData icon;
final String label;
const _NavItem(this.icon, this.label);
}

33
apps/client/local_plugins/asr_tr_tts_manager/.gitignore

@ -1,33 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/

33
apps/client/local_plugins/asr_tr_tts_manager/.metadata

@ -1,33 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: android
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: ios
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

3
apps/client/local_plugins/asr_tr_tts_manager/CHANGELOG.md

@ -1,3 +0,0 @@
## 0.0.1
* TODO: Describe initial release.

1
apps/client/local_plugins/asr_tr_tts_manager/LICENSE

@ -1 +0,0 @@
TODO: Add your license here.

15
apps/client/local_plugins/asr_tr_tts_manager/README.md

@ -1,15 +0,0 @@
# asr_tr_tts_manager
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/to/develop-plugins),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

4
apps/client/local_plugins/asr_tr_tts_manager/analysis_options.yaml

@ -1,4 +0,0 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

9
apps/client/local_plugins/asr_tr_tts_manager/android/.gitignore

@ -1,9 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save