9 changed files with 0 additions and 2499 deletions
@ -1,10 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/chat_controller.dart'; |
|||
|
|||
class ChatBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// 注册控制器 |
|||
Get.lazyPut<ChatController>(() => ChatController()); |
|||
} |
|||
} |
|||
@ -1,992 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
import '../../../data/services/spotify_service.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter/widgets.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:haptic_feedback/haptic_feedback.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:chat_storage/chat_storage.dart'; |
|||
import '../../../data/services/ai_service.dart'; |
|||
import '../../../data/services/deapsound_ai_service.dart'; |
|||
import '../../../data/services/kouzi_ai_service.dart'; |
|||
import '../../../data/services/speech_impl/voice_clone_tts_service.dart'; |
|||
import '../models/message_model.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
import '../../../data/services/tts_service.dart'; |
|||
import '../../../data/providers/agent_provider.dart'; |
|||
import '../../../data/models/agent.dart'; |
|||
import '../../../data/services/asr_service.dart'; |
|||
import '../../../data/services/open_ai_service_adapter.dart'; |
|||
|
|||
class ChatController extends GetxController { |
|||
// 服务 |
|||
late AiService _aiService; |
|||
late TtsService _ttsService; |
|||
final AsrService _asrService = Get.find<AsrService>(); |
|||
|
|||
late SpotifService _spotifService; |
|||
|
|||
// 添加ScrollController用于控制列表滚动 |
|||
final ScrollController scrollController = ScrollController(); |
|||
|
|||
StreamSubscription? _locationStream; |
|||
String _latLng = ''; |
|||
String _city = '深圳市'; |
|||
|
|||
// Agent 信息 |
|||
late final Agent agent; |
|||
|
|||
// 消息列表 |
|||
final RxList<Message> messages = <Message>[].obs; |
|||
|
|||
// 输入控制 |
|||
final RxString inputText = ''.obs; |
|||
|
|||
// 状态控制 |
|||
final RxBool isLoading = false.obs; |
|||
final RxBool isSending = false.obs; |
|||
final RxBool isTtsEnabled = true.obs; |
|||
final RxBool isVoiceRecognitionActive = false.obs; |
|||
|
|||
// 分页加载状态 |
|||
final RxInt _currentPage = 1.obs; |
|||
final RxInt _totalPages = 1.obs; |
|||
final RxBool _isLoadingMore = false.obs; |
|||
final RxBool _hasMoreHistory = true.obs; |
|||
static const int _pageSize = 20; // 每页加载的消息数量 |
|||
|
|||
// 语音交互模式 |
|||
bool _isVoiceInteractionMode = false; |
|||
|
|||
// 语音识别状态 |
|||
final RxString recognizedText = ''.obs; |
|||
final RxBool isListening = false.obs; |
|||
final RxBool isSpeechDetected = false.obs; |
|||
|
|||
// 防抖计时器 |
|||
Timer? _scrollDebounceTimer; |
|||
|
|||
// 流式响应控制 |
|||
StreamSubscription? _aiResponseSubscription; |
|||
StreamSubscription? _recognitionSubscription; |
|||
StreamSubscription? _voiceInteractionSubscription; |
|||
|
|||
// 取消标志 |
|||
bool _shouldCancelAiResponse = false; |
|||
|
|||
// 音频输入源 |
|||
bool _audioSourceType = false; |
|||
|
|||
// 自动停止语音识别计时器 |
|||
Timer? _autoStopTimer; |
|||
|
|||
// 记录最后一次活动时间 |
|||
DateTime _lastActivityTime = DateTime.now(); |
|||
|
|||
// 音乐播放 |
|||
final player = AudioPlayer(); |
|||
final RxString audiourl = ''.obs; |
|||
final RxBool isPlay = false.obs; |
|||
|
|||
// 按住说话、文字输入切换 |
|||
final RxBool isTextInput = false.obs; |
|||
final RxBool isHoldSpeak = false.obs; |
|||
String finalResult = ''; |
|||
|
|||
// 按住说话防抖 |
|||
bool isDebounce = false; |
|||
Timer? _talkDebounceTimer; |
|||
Timer? _talkStartDebounceTimer; |
|||
|
|||
@override |
|||
Future<void> onInit() async { |
|||
super.onInit(); |
|||
Logger.info('ChatController onInit'); |
|||
|
|||
// 从路由参数获取Agent信息 |
|||
final Map<String, dynamic> args = Get.arguments ?? {}; |
|||
final String agentId = |
|||
args['agent'] ?? args['agentId'] ?? 'personal_assistant'; |
|||
|
|||
// 获取Agent |
|||
final Agent? foundAgent = AgentProvider.getAgentById(agentId); |
|||
if (foundAgent == null) { |
|||
Get.back(); |
|||
Logger.error('无法找到Agent: $agentId'); |
|||
|
|||
return; |
|||
} |
|||
|
|||
agent = foundAgent; |
|||
|
|||
// 根据Agent ID选择不同的AI服务 |
|||
switch (agent.id) { |
|||
case 'cyber_girlfriend': |
|||
// 亲子陪伴 |
|||
Get.put(KouziAIService()); |
|||
_aiService = Get.find<KouziAIService>(); |
|||
break; |
|||
case 'health_advisor': |
|||
// 多功能AI聊天 |
|||
Get.put(DeapsoundAIService()); |
|||
_aiService = Get.find<DeapsoundAIService>(); |
|||
break; |
|||
default: |
|||
// 默认使用OpenAIServiceAdapter |
|||
_aiService = Get.find<OpenAIServiceAdapter>(); |
|||
Logger.info('使用OpenAIServiceAdapter'); |
|||
} |
|||
Get.put(SpotifService()); |
|||
_spotifService = Get.find<SpotifService>(); |
|||
// 使用克隆音色语音合成 |
|||
final GetStorage storage = GetStorage(); |
|||
String timbre = storage.read('timbre') ?? '1'; |
|||
if (timbre == '1') { |
|||
_ttsService = Get.find<TtsService>(); |
|||
} else { |
|||
Get.put(VoiceCloneTtsService()); |
|||
_ttsService = Get.find<VoiceCloneTtsService>(); |
|||
} |
|||
|
|||
_ttsService.initialize(); |
|||
_asrService.initialize(); |
|||
|
|||
// 设置语音交互模式 |
|||
_isVoiceInteractionMode = args['isVoiceInteraction'] == true; |
|||
|
|||
// 设置TTS服务 |
|||
await _ttsService.setVoice(agent.voice); |
|||
|
|||
// 添加滚动监听器,用于触发加载更多 |
|||
scrollController.addListener(_scrollListener); |
|||
|
|||
// 加载聊天历史 (加载第一页) |
|||
await _loadChatHistory(); |
|||
|
|||
// 如果没有历史记录,添加欢迎消息 |
|||
if (messages.isEmpty && agent.welcomeMessage.isNotEmpty) { |
|||
messages.add(Message( |
|||
role: 'assistant', |
|||
content: agent.welcomeMessage, |
|||
timestamp: DateTime.now(), |
|||
)); |
|||
// 如果启用了TTS,播放欢迎消息 |
|||
// if (isTtsEnabled.value) { |
|||
// _ttsService.speak(agent.welcomeMessage); |
|||
// } |
|||
} |
|||
|
|||
// 如果isVoiceInteraction为true,自动激活语音识别 |
|||
if (args['isVoiceInteraction'] == true) { |
|||
_ttsService.speakOnce(agent.voiceWelcomeMessage ?? agent.welcomeMessage); |
|||
startVoiceRecognition(); |
|||
// 自动停止计时器会在startVoiceRecognition中启动 |
|||
} |
|||
|
|||
|
|||
player.playerStateStream.listen((state) async { |
|||
if (state.processingState == ProcessingState.completed) { |
|||
await player.seek(Duration.zero); |
|||
player.pause(); |
|||
isPlay.value = false; |
|||
} |
|||
}); |
|||
|
|||
// 使用微任务在布局完成后自动滚动到底部 |
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
_scrollToBottom(); |
|||
}); |
|||
} |
|||
|
|||
|
|||
@override |
|||
void onClose() { |
|||
Logger.info('ChatController onClose'); |
|||
|
|||
// 清理资源 |
|||
scrollController.removeListener(_scrollListener); // 移除监听器 |
|||
scrollController.dispose(); |
|||
_aiResponseSubscription?.cancel(); |
|||
_recognitionSubscription?.cancel(); |
|||
_voiceInteractionSubscription?.cancel(); |
|||
_autoStopTimer?.cancel(); |
|||
_scrollDebounceTimer?.cancel(); // 清理防抖计时器 |
|||
_talkDebounceTimer?.cancel(); |
|||
_talkStartDebounceTimer?.cancel(); |
|||
|
|||
_locationStream?.cancel(); |
|||
|
|||
// 停止TTS |
|||
_ttsService.stop(); |
|||
|
|||
if (isVoiceRecognitionActive.value) { |
|||
_asrService.stopContinuousRecognition(); |
|||
} |
|||
|
|||
// 保存当前聊天记录 |
|||
if (agent.id != null) { |
|||
// _historyService.saveHistory(agent.id!, messages.toList()); |
|||
} |
|||
|
|||
player.dispose(); |
|||
|
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 滚动到列表底部方法,添加防抖功能 |
|||
void _scrollToBottom() { |
|||
if (scrollController.hasClients && messages.isNotEmpty) { |
|||
try { |
|||
scrollController.animateTo( |
|||
scrollController.position.maxScrollExtent, |
|||
duration: Duration(milliseconds: 300), |
|||
curve: Curves.easeOut, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('滚动到底部失败', e); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 中断当前响应 |
|||
void _interruptCurrentResponse(String reason) { |
|||
Logger.info('${reason}'); |
|||
|
|||
// 停止TTS |
|||
_ttsService.stop(); |
|||
|
|||
// 取消AI响应流订阅 |
|||
if (_aiResponseSubscription != null) { |
|||
_shouldCancelAiResponse = true; |
|||
_aiResponseSubscription!.cancel(); |
|||
_aiResponseSubscription = null; |
|||
|
|||
// 将AI中间消息转换为正常消息,保留已输出的内容 |
|||
final index = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|||
if (index != -1) { |
|||
messages[index] = messages[index].copyWith( |
|||
isIntermediate: false, |
|||
); |
|||
messages.refresh(); |
|||
} |
|||
|
|||
// 重置发送状态 |
|||
isSending.value = false; |
|||
} |
|||
} |
|||
|
|||
// 发送消息 |
|||
Future<void> sendMessage(String text) async { |
|||
if (text.trim().isEmpty) return; |
|||
|
|||
// 中断当前响应 |
|||
_interruptCurrentResponse("发送新消息,中断当前响应"); |
|||
|
|||
// 添加用户消息 |
|||
final userMessage = Message( |
|||
role: 'user', |
|||
content: text, |
|||
timestamp: DateTime.now(), |
|||
); |
|||
messages.add(userMessage); |
|||
_saveMessageToDb(userMessage); |
|||
|
|||
// 清空输入框 |
|||
inputText.value = ''; |
|||
|
|||
// 添加中间状态的AI消息 |
|||
final intermediateMessage = Message.intermediate('assistant'); |
|||
messages.add(intermediateMessage); |
|||
messages.refresh(); |
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
_scrollToBottom(); |
|||
}); |
|||
|
|||
isSending.value = true; |
|||
|
|||
try { |
|||
// 构建消息历史 |
|||
final List<Map<String, String>> messageHistory = _buildMessageHistory(); |
|||
|
|||
// 重置取消标志 |
|||
_shouldCancelAiResponse = false; |
|||
|
|||
String fullResponse = ''; |
|||
|
|||
// 获取AI响应流 |
|||
final responseStream = _aiService.sendMessageStream( |
|||
messages: messageHistory, |
|||
systemPrompt: agent.systemPrompt, |
|||
); |
|||
Logger.info( |
|||
'liwei----------- 发送用户提问 ${text}', |
|||
); |
|||
// 订阅响应流 |
|||
_aiResponseSubscription = responseStream.listen( |
|||
(chunk) { |
|||
if (_shouldCancelAiResponse) return; |
|||
Logger.info('chat ${chunk}'); |
|||
if (chunk is Map) { |
|||
Logger.info( |
|||
'liwei-----------收到卡片消息 ${chunk}', |
|||
); |
|||
_updateIntermediateMessage(meta: chunk); |
|||
if (chunk['card_spotify'] != null) { |
|||
play_spotify(chunk['card_spotify']); |
|||
} |
|||
if (chunk['card_music'] != null) { |
|||
play_music(chunk['card_music']); |
|||
} |
|||
} else if (chunk is String) { |
|||
fullResponse += chunk; |
|||
|
|||
// 更新中间状态的消息内容 |
|||
_updateIntermediateMessage(content: fullResponse); |
|||
|
|||
// 如果TTS启用,使用流式处理发送到TTS |
|||
if (isTtsEnabled.value) { |
|||
String str = chunk.replaceAll('*', ''); |
|||
_ttsService.speakStream(str); |
|||
} |
|||
} |
|||
}, |
|||
onDone: () { |
|||
Logger.info( |
|||
'liwei-----------结束当前问题 ${text}', |
|||
); |
|||
if (_shouldCancelAiResponse) return; |
|||
|
|||
// 完成响应,将isIntermediate设为false |
|||
_finalizeResponse(fullResponse); |
|||
|
|||
// 如果TTS启用,处理缓冲区中的剩余文本 |
|||
if (isTtsEnabled.value) { |
|||
_ttsService.flushStream(); |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
Logger.error('AI响应错误', error); |
|||
|
|||
// 更新中间状态的消息为错误消息 |
|||
_handleResponseError(); |
|||
}, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('发送消息失败', e); |
|||
|
|||
_handleResponseError(); |
|||
} |
|||
} |
|||
|
|||
// 构建消息历史 |
|||
List<Map<String, String>> _buildMessageHistory() { |
|||
final List<Map<String, String>> messageHistory = []; |
|||
|
|||
// 只取最近的10条消息作为上下文 |
|||
final recentMessages = messages.where((m) => !m.isIntermediate).toList(); |
|||
final startIndex = |
|||
recentMessages.length > 10 ? recentMessages.length - 10 : 0; |
|||
messageHistory.add({ |
|||
'role': 'user', |
|||
'content': '我当前所在城市是$_city,我的经纬度是$_latLng', |
|||
}); |
|||
|
|||
for (int i = startIndex; i < recentMessages.length; i++) { |
|||
final m = recentMessages[i]; |
|||
messageHistory.add({ |
|||
'role': m.role, |
|||
'content': m.content, |
|||
}); |
|||
} |
|||
|
|||
return messageHistory; |
|||
} |
|||
|
|||
// 更新中间状态的消息 |
|||
void _updateIntermediateMessage({Map? meta, String? content}) { |
|||
final index = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|||
if (index != -1) { |
|||
if (meta != null) { |
|||
Map messagesData = messages[index].meta ?? {}; |
|||
messages[index] = messages[index].copyWith( |
|||
meta: {...messagesData, ...meta}, |
|||
); |
|||
} |
|||
messages[index] = messages[index].copyWith( |
|||
content: content, |
|||
); |
|||
messages.refresh(); |
|||
_scrollToBottom(); |
|||
} |
|||
} |
|||
|
|||
// 播放音乐 |
|||
void play_music(Map card) async { |
|||
try { |
|||
isPlay.value = false; |
|||
if (audiourl.value != card['url']) { |
|||
audiourl.value = ''; |
|||
await player.setUrl(card['url']); |
|||
audiourl.value = card['url']; |
|||
player.play(); |
|||
} else { |
|||
player.play(); |
|||
} |
|||
isPlay.value = true; |
|||
} catch (e) { |
|||
Logger.error('加载异常: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
// 播放音乐 |
|||
void pause_music() async { |
|||
await player.pause(); |
|||
isPlay.value = false; |
|||
} |
|||
|
|||
// 播放音乐 |
|||
void play_spotify(Map card) async { |
|||
try { |
|||
await _spotifService.initialize(); |
|||
_spotifService.play(card['url'] as String); |
|||
isPlay.value = true; |
|||
audiourl.value = card['url'] as String; |
|||
} catch (e) { |
|||
Logger.error('加载异常: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
// 暂停播放音乐 |
|||
void pause_spotify() async { |
|||
try { |
|||
_spotifService.pause(); |
|||
isPlay.value = false; |
|||
} catch (e) { |
|||
Logger.error('加载异常: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
// 更新或添加最终响应 |
|||
void _finalizeResponse(String content) { |
|||
// 查找AI的中间状态消息 |
|||
final index = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|||
if (index != -1) { |
|||
// 更新中间状态消息为最终状态 |
|||
messages[index] = messages[index].copyWith( |
|||
content: content, |
|||
isIntermediate: false, |
|||
timestamp: DateTime.now(), // 更新时间戳为最终时间 |
|||
); |
|||
// 保存最终的助手消息到数据库 |
|||
_saveMessageToDb(messages[index]); |
|||
} else { |
|||
// 如果没有中间状态消息,直接添加新消息 (理论上不应发生) |
|||
final assistantMsg = Message( |
|||
role: 'assistant', |
|||
content: content, |
|||
timestamp: DateTime.now(), |
|||
); |
|||
messages.add(assistantMsg); |
|||
_saveMessageToDb(assistantMsg); // 保存到数据库 |
|||
} |
|||
|
|||
isLoading.value = false; |
|||
isSending.value = false; |
|||
_aiResponseSubscription = null; // 清理订阅 |
|||
_shouldCancelAiResponse = false; // 重置取消标志 |
|||
|
|||
// 响应完成后,重新启动语音识别的自动停止计时器 |
|||
if (_isVoiceInteractionMode) { |
|||
_startAutoStopTimer(); |
|||
} |
|||
} |
|||
|
|||
// 处理响应错误 |
|||
void _handleResponseError() { |
|||
// 查找AI的中间状态消息 |
|||
final index = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|||
if (index != -1) { |
|||
messages[index] = messages[index].copyWith( |
|||
content: '抱歉,我遇到了一些问题,无法回应您的请求。', |
|||
isIntermediate: false, |
|||
); |
|||
messages.refresh(); |
|||
} |
|||
|
|||
isSending.value = false; |
|||
_aiResponseSubscription = null; |
|||
} |
|||
|
|||
// 切换TTS状态 |
|||
void toggleTts() { |
|||
isTtsEnabled.toggle(); |
|||
|
|||
// 如果关闭TTS,停止当前播放 |
|||
if (!isTtsEnabled.value) { |
|||
_ttsService.stop(); |
|||
} |
|||
} |
|||
|
|||
// 按下开始说话 |
|||
void talkLongPress() async { |
|||
_talkStartDebounceTimer?.cancel(); |
|||
_talkDebounceTimer?.cancel(); |
|||
isHoldSpeak.value = true; |
|||
Haptics.vibrate(HapticsType.success); |
|||
|
|||
_talkStartDebounceTimer = |
|||
Timer(const Duration(milliseconds: 260), () async { |
|||
if (isDebounce) return; |
|||
|
|||
// 中断当前响应 |
|||
_interruptCurrentResponse("发送新消息,中断当前响应"); |
|||
try { |
|||
final recognitionStream = |
|||
await _asrService.startContinuousRecognition(_audioSourceType); |
|||
await Future.delayed(const Duration(milliseconds: 260)); |
|||
isDebounce = true; |
|||
recognizedText.value = ''; |
|||
finalResult = ''; |
|||
|
|||
// 监听识别事件 |
|||
_recognitionSubscription = recognitionStream.listen( |
|||
_handleRecognitionEvent, |
|||
onError: _handleRecognitionError, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('启动语音识别失败', e); |
|||
isHoldSpeak.value = false; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 松开停止说话 |
|||
void talkLongPressUp() async { |
|||
_talkStartDebounceTimer?.cancel(); |
|||
_talkDebounceTimer?.cancel(); |
|||
isHoldSpeak.value = false; |
|||
|
|||
_talkDebounceTimer = Timer(const Duration(milliseconds: 300), () async { |
|||
if (!isDebounce) return; |
|||
try { |
|||
await _asrService.stopContinuousRecognition(); |
|||
_recognitionSubscription?.cancel(); |
|||
_recognitionSubscription = null; |
|||
|
|||
String sendText = ''; |
|||
|
|||
final intermediateIndex = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|||
if (intermediateIndex >= 0) { |
|||
sendText = messages[intermediateIndex].content; |
|||
messages.removeAt(intermediateIndex); |
|||
} |
|||
|
|||
sendMessage(sendText); |
|||
|
|||
recognizedText.value = ''; |
|||
finalResult = ''; |
|||
|
|||
isDebounce = false; |
|||
} catch (e) { |
|||
Logger.error('停止语音识别失败', e); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 开始语音识别 |
|||
Future<void> startVoiceRecognition() async { |
|||
if (isVoiceRecognitionActive.value) return; |
|||
|
|||
try { |
|||
// 启动连续语音识别 |
|||
// 注意:在UI更新后,使用模拟波形动画替代实际麦克风输入的波形数据 |
|||
final recognitionStream = |
|||
await _asrService.startContinuousRecognition(_audioSourceType); |
|||
|
|||
isVoiceRecognitionActive.value = true; |
|||
isListening.value = true; |
|||
recognizedText.value = ''; |
|||
|
|||
// 启动自动停止计时器 |
|||
_startAutoStopTimer(); |
|||
|
|||
// 监听识别事件 |
|||
_recognitionSubscription = recognitionStream.listen( |
|||
_handleRecognitionEvent, |
|||
onError: _handleRecognitionError, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('启动语音识别失败', e); |
|||
isVoiceRecognitionActive.value = false; |
|||
isListening.value = false; |
|||
} |
|||
} |
|||
|
|||
// 处理语音识别事件 |
|||
void _handleRecognitionEvent(RecognitionEvent event) { |
|||
// 更新活动时间 |
|||
_updateActivityTime(); |
|||
|
|||
switch (event.type) { |
|||
case RecognitionEventType.intermediateResult: |
|||
_handleRecognizing(event.text); |
|||
break; |
|||
case RecognitionEventType.finalResult: |
|||
_handleFinalResult(event.text); |
|||
break; |
|||
case RecognitionEventType.error: |
|||
_handleRecognitionEventError(event.error); |
|||
break; |
|||
default: |
|||
// 不做任何处理 |
|||
break; |
|||
} |
|||
} |
|||
|
|||
// 更新最后活动时间 |
|||
void _updateActivityTime() { |
|||
_lastActivityTime = DateTime.now(); |
|||
} |
|||
|
|||
// 启动自动停止计时器 |
|||
void _startAutoStopTimer() { |
|||
// 取消现有计时器 |
|||
_autoStopTimer?.cancel(); |
|||
|
|||
// 初始化最后活动时间 |
|||
_updateActivityTime(); |
|||
|
|||
// 根据模式设置超时时间 |
|||
final int timeoutSeconds = _isVoiceInteractionMode ? 8 : 15; |
|||
|
|||
// 创建新的计时器,每秒检查一次 |
|||
_autoStopTimer = Timer.periodic(Duration(seconds: 1), (timer) { |
|||
// 检查是否有活动 |
|||
bool isActive = _ttsService.isSpeaking || isSpeechDetected.value; |
|||
|
|||
// 如果有活动,更新时间并继续等待 |
|||
if (isActive) { |
|||
_updateActivityTime(); |
|||
return; |
|||
} |
|||
|
|||
// 检查空闲时间 |
|||
int idleSeconds = DateTime.now().difference(_lastActivityTime).inSeconds; |
|||
if (idleSeconds >= timeoutSeconds) { |
|||
timer.cancel(); |
|||
_stopVoiceRecognitionAutomatically(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 处理语音识别中的状态 |
|||
void _handleRecognizing(String text) { |
|||
if (text.isEmpty) return; |
|||
|
|||
recognizedText.value = text; |
|||
|
|||
// 设置语音检测状态为true,用于控制波形动画 |
|||
if (!isSpeechDetected.value) { |
|||
isSpeechDetected.value = true; |
|||
} |
|||
|
|||
// 更新活动时间 |
|||
_updateActivityTime(); |
|||
|
|||
// 用户开始讲话时,立即停止当前TTS和中断AI响应 |
|||
if (isSending.value || _aiResponseSubscription != null) { |
|||
_interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); |
|||
} |
|||
|
|||
// 查找或创建用户的中间状态消息 |
|||
final intermediateIndex = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|||
|
|||
if (intermediateIndex >= 0) { |
|||
// 更新已有的中间状态消息 |
|||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|||
content: isVoiceRecognitionActive.value ? text : finalResult + text, |
|||
); |
|||
messages.refresh(); |
|||
} else { |
|||
// 添加新的中间状态消息 |
|||
var intermediateMessage = Message.intermediate('user'); |
|||
intermediateMessage = intermediateMessage.copyWith(content: text); |
|||
messages.add(intermediateMessage); |
|||
messages.refresh(); |
|||
} |
|||
|
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
_scrollToBottom(); |
|||
}); |
|||
} |
|||
|
|||
// 处理语音识别最终结果 |
|||
void _handleFinalResult(String text) { |
|||
if (text.isEmpty) return; |
|||
|
|||
Logger.info('处理语音识别最终结果: $text'); |
|||
|
|||
// 更新活动时间 |
|||
_updateActivityTime(); |
|||
|
|||
if (isVoiceRecognitionActive.value) { |
|||
// 一直语音说话 |
|||
|
|||
recognizedText.value = text; |
|||
// 查找并移除用户的中间状态消息 |
|||
final intermediateIndex = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|||
if (intermediateIndex >= 0) { |
|||
messages.removeAt(intermediateIndex); |
|||
} |
|||
|
|||
// 发送识别到的文本 |
|||
sendMessage(text); |
|||
// 重置识别文本,但不关闭语音识别框 |
|||
recognizedText.value = ''; |
|||
// 重置语音检测状态 |
|||
isSpeechDetected.value = false; |
|||
} else { |
|||
// 按住说话 |
|||
|
|||
finalResult += text; |
|||
recognizedText.value = finalResult; |
|||
|
|||
final intermediateIndex = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|||
|
|||
if (intermediateIndex >= 0) { |
|||
// 更新已有的中间状态消息 |
|||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|||
content: finalResult, |
|||
); |
|||
messages.refresh(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 处理语音识别事件错误 |
|||
void _handleRecognitionEventError(String? error) { |
|||
Logger.error('语音识别错误: ${error ?? "未知错误"}'); |
|||
|
|||
_cleanupRecognition(); |
|||
} |
|||
|
|||
// 处理语音识别流错误 |
|||
void _handleRecognitionError(dynamic error) { |
|||
Logger.error('语音识别错误', error); |
|||
|
|||
_cleanupRecognition(); |
|||
} |
|||
|
|||
// 清理语音识别资源 |
|||
void _cleanupRecognition() { |
|||
// 查找并移除用户的中间状态消息 |
|||
final intermediateIndex = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|||
if (intermediateIndex >= 0) { |
|||
if (messages[intermediateIndex].content.trim().isEmpty) { |
|||
messages.removeAt(intermediateIndex); |
|||
} else { |
|||
// 如果中间状态消息有内容,将其转换为正式消息 |
|||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|||
isIntermediate: false, |
|||
); |
|||
} |
|||
messages.refresh(); |
|||
} |
|||
|
|||
// 停止语音识别 |
|||
isVoiceRecognitionActive.value = false; |
|||
isListening.value = false; |
|||
isSpeechDetected.value = false; |
|||
_asrService.stopContinuousRecognition(); |
|||
} |
|||
|
|||
// 停止语音识别 |
|||
Future<void> stopVoiceRecognition() async { |
|||
if (!isVoiceRecognitionActive.value) return; |
|||
|
|||
try { |
|||
await _asrService.stopContinuousRecognition(); |
|||
_recognitionSubscription?.cancel(); |
|||
_recognitionSubscription = null; |
|||
|
|||
isVoiceRecognitionActive.value = false; |
|||
isListening.value = false; |
|||
recognizedText.value = ''; |
|||
isSpeechDetected.value = false; |
|||
|
|||
// 查找并处理用户的中间状态消息 |
|||
final intermediateIndex = |
|||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|||
if (intermediateIndex >= 0) { |
|||
if (messages[intermediateIndex].content.trim().isEmpty) { |
|||
// 如果没有内容则移除 |
|||
messages.removeAt(intermediateIndex); |
|||
} else { |
|||
// 有内容则转为正式消息 |
|||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|||
isIntermediate: false, |
|||
); |
|||
} |
|||
messages.refresh(); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('停止语音识别失败', e); |
|||
} |
|||
} |
|||
|
|||
// 清空聊天记录 |
|||
void clearChat() { |
|||
messages.clear(); |
|||
_ttsService.stop(); |
|||
// 添加欢迎消息 |
|||
if (agent.welcomeMessage.isNotEmpty) { |
|||
messages.add(Message( |
|||
role: 'assistant', |
|||
content: agent.welcomeMessage, |
|||
timestamp: DateTime.now(), |
|||
)); |
|||
} |
|||
// 删除数据库中的历史记录 |
|||
ChatStorage.deleteMessages(sessionId: agent.id).catchError((e) { |
|||
Logger.error('清空数据库聊天记录失败: ${e.toString()}'); |
|||
}); |
|||
} |
|||
|
|||
// 滚动监听,用于触发加载更多历史记录 |
|||
void _scrollListener() { |
|||
// 当滚动到顶部并且有更多历史记录且不在加载中时,加载更多 |
|||
if (scrollController.position.pixels == |
|||
scrollController.position.minScrollExtent && |
|||
_hasMoreHistory.value && |
|||
!_isLoadingMore.value) { |
|||
_loadMoreHistory(); |
|||
} |
|||
} |
|||
|
|||
// 加载聊天历史 (首次加载或刷新) |
|||
Future<void> _loadChatHistory() async { |
|||
isLoading.value = true; |
|||
_currentPage.value = 1; // 重置为第一页 |
|||
_hasMoreHistory.value = true; // 假设有更多 |
|||
|
|||
try { |
|||
final pagedResult = await ChatStorage.getMessages( |
|||
sessionId: agent.id, |
|||
page: _currentPage.value, |
|||
pageSize: _pageSize, |
|||
); |
|||
|
|||
_totalPages.value = pagedResult.totalPages; |
|||
_hasMoreHistory.value = _currentPage.value < _totalPages.value; |
|||
|
|||
final historyMessages = pagedResult.messages.map((chatMsg) { |
|||
return Message( |
|||
role: chatMsg.sender, |
|||
content: chatMsg.message, |
|||
timestamp: DateTime.parse(chatMsg.timestamp), |
|||
meta: chatMsg.metadata != null ? jsonDecode(chatMsg.metadata!) : null, |
|||
); |
|||
}).toList(); |
|||
|
|||
messages.assignAll(historyMessages.reversed); // 最新的在底部,所以要反转 |
|||
|
|||
// 滚动到底部 (如果是首次加载) |
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
if (scrollController.hasClients) { |
|||
scrollController.jumpTo(scrollController.position.maxScrollExtent); |
|||
} |
|||
}); |
|||
} catch (e) { |
|||
Logger.error('加载聊天历史失败', e); |
|||
} finally { |
|||
isLoading.value = false; |
|||
} |
|||
} |
|||
|
|||
// 加载更多历史记录 |
|||
Future<void> _loadMoreHistory() async { |
|||
if (_isLoadingMore.value || !_hasMoreHistory.value) return; |
|||
|
|||
_isLoadingMore.value = true; |
|||
_currentPage.value++; |
|||
|
|||
try { |
|||
final pagedResult = await ChatStorage.getMessages( |
|||
sessionId: agent.id, |
|||
page: _currentPage.value, |
|||
pageSize: _pageSize, |
|||
); |
|||
|
|||
_totalPages.value = pagedResult.totalPages; |
|||
_hasMoreHistory.value = _currentPage.value < _totalPages.value; |
|||
|
|||
final olderMessages = pagedResult.messages.map((chatMsg) { |
|||
return Message( |
|||
role: chatMsg.sender, |
|||
content: chatMsg.message, |
|||
timestamp: DateTime.parse(chatMsg.timestamp), |
|||
meta: chatMsg.metadata != null ? jsonDecode(chatMsg.metadata!) : null, |
|||
); |
|||
}).toList(); |
|||
|
|||
// 将旧消息插入到列表顶部 |
|||
messages.insertAll(0, olderMessages.reversed); |
|||
} catch (e) { |
|||
Logger.error('加载更多聊天历史失败', e); |
|||
_currentPage.value--; // 加载失败,恢复页码 |
|||
} finally { |
|||
_isLoadingMore.value = false; |
|||
} |
|||
} |
|||
|
|||
// 保存单条聊天消息到数据库 |
|||
Future<void> _saveMessageToDb(Message message) async { |
|||
if (message.isIntermediate) return; // 不保存中间状态的消息 |
|||
try { |
|||
await ChatStorage.saveMessage( |
|||
sessionId: agent.id, |
|||
message: message.content, |
|||
sender: message.role, |
|||
metadata: jsonEncode(message.meta), |
|||
); |
|||
} catch (e) { |
|||
Logger.error('保存消息到数据库失败', e); |
|||
} |
|||
} |
|||
|
|||
// 添加消息,供外部调用 (如用户发送消息、AI回复) |
|||
void addMessage(Message message) { |
|||
messages.add(message); |
|||
_saveMessageToDb(message); // 保存到数据库 |
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
_scrollToBottom(); |
|||
}); |
|||
} |
|||
|
|||
void _stopVoiceRecognitionAutomatically() { |
|||
final int timeoutSeconds = _isVoiceInteractionMode ? 8 : 15; |
|||
Logger.info('${timeoutSeconds}秒无活动,自动停止语音识别'); |
|||
stopVoiceRecognition(); |
|||
} |
|||
|
|||
/// 检查是否为重复消息 |
|||
bool _isDuplicateMessage(Message message) { |
|||
return messages.any((m) => |
|||
m.role == message.role && |
|||
m.content == message.content && |
|||
(m.timestamp.millisecondsSinceEpoch - |
|||
message.timestamp.millisecondsSinceEpoch) |
|||
.abs() < |
|||
5000); |
|||
} |
|||
} |
|||
@ -1,64 +0,0 @@ |
|||
class Message { |
|||
final String role; // 'user' or 'assistant' |
|||
final String content; |
|||
final Map? meta; |
|||
final DateTime timestamp; |
|||
final bool isIntermediate; // 中间状态标记,替代isLoading |
|||
|
|||
Message({ |
|||
required this.role, |
|||
required this.content, |
|||
required this.timestamp, |
|||
this.meta, |
|||
this.isIntermediate = false, // 默认不是中间状态 |
|||
}); |
|||
|
|||
// 从JSON构造函数 |
|||
factory Message.fromJson(Map<String, dynamic> json) { |
|||
return Message( |
|||
role: json['role'] as String, |
|||
content: json['content'] as String, |
|||
meta: json['meta'], |
|||
timestamp: DateTime.parse(json['timestamp'] as String), |
|||
isIntermediate: json['isIntermediate'] as bool? ?? false, |
|||
); |
|||
} |
|||
|
|||
// 转换为JSON |
|||
Map<String, dynamic> toJson() { |
|||
return { |
|||
'role': role, |
|||
'content': content, |
|||
'meta': meta, |
|||
'timestamp': timestamp.toIso8601String(), |
|||
'isIntermediate': isIntermediate, |
|||
}; |
|||
} |
|||
|
|||
// 创建一个中间状态的消息 |
|||
factory Message.intermediate(String role) { |
|||
return Message( |
|||
role: role, |
|||
content: '', |
|||
timestamp: DateTime.now(), |
|||
isIntermediate: true, |
|||
); |
|||
} |
|||
|
|||
// 复制并修改 |
|||
Message copyWith({ |
|||
String? role, |
|||
String? content, |
|||
Map? meta, |
|||
DateTime? timestamp, |
|||
bool? isIntermediate, |
|||
}) { |
|||
return Message( |
|||
role: role ?? this.role, |
|||
content: content ?? this.content, |
|||
meta: meta ?? this.meta, |
|||
timestamp: timestamp ?? this.timestamp, |
|||
isIntermediate: isIntermediate ?? this.isIntermediate, |
|||
); |
|||
} |
|||
} |
|||
@ -1,69 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
|
|||
class AnimatedGradientBackground extends StatefulWidget { |
|||
final Widget child; |
|||
|
|||
const AnimatedGradientBackground(this.child, {super.key}); |
|||
|
|||
@override |
|||
State<AnimatedGradientBackground> createState() => |
|||
_AnimatedGradientBackgroundState(); |
|||
} |
|||
|
|||
class _AnimatedGradientBackgroundState extends State<AnimatedGradientBackground> |
|||
with SingleTickerProviderStateMixin { |
|||
late AnimationController _controller; |
|||
late Animation<Alignment?> _beginAnimation; |
|||
late Animation<Alignment?> _endAnimation; |
|||
|
|||
final Color color1 = const Color(0xFFfbcfdd); |
|||
final Color color2 = const Color(0xFFe2e9fb); |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
|
|||
_controller = AnimationController( |
|||
vsync: this, |
|||
duration: const Duration(seconds: 10), |
|||
)..repeat(reverse: true); |
|||
|
|||
// 创建起始点动画 |
|||
_beginAnimation = AlignmentTween( |
|||
begin: Alignment.topCenter, |
|||
end: Alignment.bottomLeft, |
|||
).animate(_controller); |
|||
|
|||
// 创建结束点动画 |
|||
_endAnimation = AlignmentTween( |
|||
begin: Alignment.bottomCenter, |
|||
end: Alignment.topRight, |
|||
).animate(_controller); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_controller.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return AnimatedBuilder( |
|||
animation: _controller, |
|||
builder: (context, child) { |
|||
return Container( |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
colors: [color1, color2], |
|||
begin: _beginAnimation.value!, |
|||
end: _endAnimation.value!, |
|||
), |
|||
), |
|||
child: child, |
|||
); |
|||
}, |
|||
child: widget.child, |
|||
); |
|||
} |
|||
} |
|||
@ -1,100 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../controllers/chat_controller.dart'; |
|||
|
|||
class ChatMusicCard extends GetView<ChatController> { |
|||
final int type; |
|||
final Map? data; |
|||
final bool isDarkMode; |
|||
|
|||
const ChatMusicCard(this.type, this.data, this.isDarkMode, {super.key}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
// final Map musiccards = data?['musiccards'].first; |
|||
// final Map musicaudio = data?['musicaudio']; |
|||
|
|||
return Row( |
|||
children: [ |
|||
Container( |
|||
width: 60.w, |
|||
height: 60.w, |
|||
clipBehavior: Clip.hardEdge, |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(6.r), |
|||
), |
|||
child: Image.network( |
|||
data?['image'], |
|||
fit: BoxFit.cover, |
|||
), |
|||
), |
|||
10.horizontalSpace, |
|||
Expanded( |
|||
child: Text( |
|||
data?['name'], |
|||
overflow: TextOverflow.ellipsis, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 16.sp, |
|||
height: 1.5, |
|||
), |
|||
), |
|||
), |
|||
10.horizontalSpace, |
|||
Obx( |
|||
() => controller.audiourl.value == data?["url"] && |
|||
controller.isPlay.value |
|||
? GestureDetector( |
|||
onTap: () { |
|||
switch (type) { |
|||
case 0: |
|||
controller.pause_music(); |
|||
break; |
|||
case 1: |
|||
controller.pause_spotify(); |
|||
break; |
|||
default: |
|||
} |
|||
}, |
|||
child: Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
Icon( |
|||
Icons.pause_rounded, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
], |
|||
), |
|||
) |
|||
: GestureDetector( |
|||
onTap: () { |
|||
switch (type) { |
|||
case 0: |
|||
controller.play_music(data!); |
|||
break; |
|||
case 1: |
|||
controller.play_spotify(data!); |
|||
break; |
|||
default: |
|||
} |
|||
}, |
|||
child: Container( |
|||
width: 38.w, |
|||
height: 38.w, |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon( |
|||
Icons.play_arrow_rounded, |
|||
color: isDarkMode ? Colors.black : Colors.white, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
} |
|||
@ -1,208 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
import 'dart:math'; |
|||
import 'dart:math' as math; |
|||
|
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:http/http.dart' as http; |
|||
|
|||
class ChatNavigationCard extends StatefulWidget { |
|||
final String content; |
|||
final Map? data; |
|||
final bool isDarkMode; |
|||
|
|||
const ChatNavigationCard(this.data, this.content, this.isDarkMode, |
|||
{super.key}); |
|||
|
|||
@override |
|||
State<ChatNavigationCard> createState() => _ChatNavigationCardState(); |
|||
} |
|||
|
|||
class _ChatNavigationCardState extends State<ChatNavigationCard> { |
|||
final String apiKey = '8bde023b7e5e00f321ea17929f10fbae'; |
|||
LatLng startPoint = LatLng(116.397428, 39.90923); // 起点坐标 |
|||
LatLng endPoint = LatLng(116.397026, 39.903303); // 终点坐标 |
|||
|
|||
late Future _mapUrl; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_getLocation(); |
|||
} |
|||
|
|||
void _getLocation() { |
|||
List startPointList = widget.data?['location'].split(','); |
|||
List endPointList = widget.data?['destination'].split(','); |
|||
startPoint = LatLng( |
|||
double.parse(startPointList[0]), |
|||
double.parse(startPointList[1]), |
|||
); |
|||
endPoint = LatLng( |
|||
double.parse(endPointList[0]), |
|||
double.parse(endPointList[1]), |
|||
); |
|||
_mapUrl = _getMapUrl(); |
|||
} |
|||
|
|||
Future<String> _getMapUrl() async { |
|||
// 1. 获取路径数据 |
|||
final route = await _fetchRouteData(); |
|||
|
|||
// 2. 解析并简化路径 |
|||
List<LatLng> simplified = _simplifyPath(route, epsilon: 0.0001); |
|||
|
|||
// 3. 计算地图参数 |
|||
final zoom = _calculateAutoZoom(simplified); |
|||
|
|||
// 4. 生成静态地图URL |
|||
return _buildStaticMapUrl(simplified, zoom); |
|||
} |
|||
|
|||
Future<List<LatLng>> _fetchRouteData() async { |
|||
final url = 'https://restapi.amap.com/v3/direction/driving?' |
|||
'origin=${startPoint.lng},${startPoint.lat}&' |
|||
'destination=${endPoint.lng},${endPoint.lat}&' |
|||
'key=$apiKey'; |
|||
|
|||
final response = await http.get(Uri.parse(url)); |
|||
if (response.statusCode == 200) { |
|||
final data = json.decode(response.body); |
|||
return _parsePolyline(data['route']['paths'][0]['steps']); |
|||
} |
|||
throw Exception('Failed to load route'); |
|||
} |
|||
|
|||
List<LatLng> _parsePolyline(List<dynamic> steps) { |
|||
final List<LatLng> path = []; |
|||
for (final step in steps) { |
|||
final points = (step['polyline'] as String).split(';'); |
|||
for (final point in points) { |
|||
final coords = point.split(',').map(double.parse).toList(); |
|||
path.add(LatLng(coords[0], coords[1])); |
|||
} |
|||
} |
|||
return path; |
|||
} |
|||
|
|||
List<LatLng> _simplifyPath(List<LatLng> points, {double epsilon = 0.0001}) { |
|||
return douglasPeucker(points, epsilon); |
|||
} |
|||
|
|||
int _calculateAutoZoom(List<LatLng> points, |
|||
{int mapWidth = 1024, int mapHeight = 512}) { |
|||
if (points.isEmpty) return 15; // 默认缩放级别 |
|||
|
|||
// 1. 计算坐标范围 |
|||
double minLng = double.infinity; |
|||
double maxLng = -double.infinity; |
|||
double minLat = double.infinity; |
|||
double maxLat = -double.infinity; |
|||
|
|||
for (final p in points) { |
|||
final lng = p.lng; |
|||
final lat = p.lat; |
|||
|
|||
minLng = math.min(minLng, lng); |
|||
maxLng = math.max(maxLng, lng); |
|||
minLat = math.min(minLat, lat); |
|||
maxLat = math.max(maxLat, lat); |
|||
} |
|||
|
|||
// 3. 计算缩放级别(基于Leaflet算法改进) |
|||
const worldWidth = 256; // 高德瓦片尺寸 |
|||
const worldHeight = 256; |
|||
const zoomMax = 18; // 高德最大缩放级别 |
|||
|
|||
double latRad(double lat) { |
|||
final sin = math.sin(lat * math.pi / 180); |
|||
return math.log((1 + sin) / (1 - sin)) / 2; |
|||
} |
|||
|
|||
final latFraction = (latRad(maxLat) - latRad(minLat)) / math.pi; |
|||
final lngDiff = maxLng - minLng; |
|||
final lngFraction = ((lngDiff < 0) ? (lngDiff + 360) : lngDiff) / 360; |
|||
|
|||
final latZoom = |
|||
math.log((mapHeight / worldHeight) / latFraction) / math.ln2; |
|||
final lngZoom = math.log((mapWidth / worldWidth) / lngFraction) / math.ln2; |
|||
|
|||
return math.min(math.min(latZoom, lngZoom).floor(), zoomMax); |
|||
} |
|||
|
|||
String _buildStaticMapUrl(List<LatLng> path, int zoom) { |
|||
final pathParam = path.map((p) => '${p.lng},${p.lat}').join(';'); |
|||
return 'https://restapi.amap.com/v3/staticmap?' |
|||
'zoom=$zoom&' |
|||
'size=1024*512&' |
|||
'paths=10,0x0000ff,1,,:$pathParam&' |
|||
'markers=mid,,A:${startPoint.lng},${startPoint.lat}|' |
|||
'mid,,B:${endPoint.lng},${endPoint.lat}&' |
|||
'key=$apiKey'; |
|||
} |
|||
|
|||
List<LatLng> douglasPeucker(List<LatLng> points, double epsilon) { |
|||
if (points.length < 3) return points; |
|||
|
|||
double maxDistance = 0; |
|||
int index = 0; |
|||
|
|||
for (int i = 1; i < points.length - 1; i++) { |
|||
double d = _perpendicularDistance(points[i], points.first, points.last); |
|||
if (d > maxDistance) { |
|||
index = i; |
|||
maxDistance = d; |
|||
} |
|||
} |
|||
|
|||
if (maxDistance > epsilon) { |
|||
return [ |
|||
...douglasPeucker(points.sublist(0, index + 1), epsilon), |
|||
...douglasPeucker(points.sublist(index), epsilon).skip(1) |
|||
]; |
|||
} |
|||
return [points.first, points.last]; |
|||
} |
|||
|
|||
double _perpendicularDistance(LatLng point, LatLng start, LatLng end) { |
|||
final area = (end.lng - start.lng) * (point.lat - start.lat) - |
|||
(end.lat - start.lat) * (point.lng - start.lng); |
|||
final length = |
|||
sqrt(pow(end.lng - start.lng, 2) + pow(end.lat - start.lat, 2)); |
|||
return area.abs() / length; |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Column( |
|||
children: [ |
|||
FutureBuilder( |
|||
future: _mapUrl, |
|||
builder: (BuildContext context, AsyncSnapshot snapshot) { |
|||
if (snapshot.hasData) { |
|||
return Image.network(snapshot.data); |
|||
} |
|||
return const CircularProgressIndicator(); |
|||
}, |
|||
), |
|||
10.verticalSpace, |
|||
Text( |
|||
widget.content, |
|||
style: TextStyle( |
|||
color: widget.isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 16.sp, |
|||
height: 1.5, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
} |
|||
|
|||
class LatLng { |
|||
final double lat; |
|||
final double lng; |
|||
|
|||
LatLng(this.lng, this.lat); // 高德使用经度在前纬度在后的格式 |
|||
} |
|||
@ -1,820 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'dart:math'; |
|||
import '../controllers/chat_controller.dart'; |
|||
import '../models/message_model.dart'; |
|||
import '../../../core/theme/app_colors.dart'; |
|||
import 'animated_gradient_background.dart'; |
|||
import 'chat_music_card.dart'; |
|||
import 'chat_navigation_card.dart'; |
|||
import 'cloud_background.dart'; |
|||
import 'weather_day_card.dart'; |
|||
|
|||
class ChatView extends GetView<ChatController> { |
|||
const ChatView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Obx( |
|||
() => Scaffold( |
|||
backgroundColor: |
|||
isDarkMode ? AppColors.darkBackground : Colors.grey[50], |
|||
appBar: controller.isVoiceRecognitionActive.value |
|||
? null |
|||
: _buildAppBar(isDarkMode), |
|||
body: controller.isVoiceRecognitionActive.value |
|||
? _buildVoiceInputPanel(isDarkMode) |
|||
: _buildBody(isDarkMode), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建AppBar |
|||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|||
return AppBar( |
|||
elevation: 1, |
|||
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
leading: IconButton( |
|||
icon: Icon(Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black87, size: 20.r), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
titleSpacing: 0, |
|||
title: Row( |
|||
children: [ |
|||
// AI头像 |
|||
Container( |
|||
width: 40.r, |
|||
height: 40.r, |
|||
decoration: BoxDecoration( |
|||
color: AppColors.primary, |
|||
borderRadius: BorderRadius.circular(20.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.1), |
|||
blurRadius: 3, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: ClipRRect( |
|||
borderRadius: BorderRadius.circular(20.r), |
|||
child: controller.agent.avatarUrl.isNotEmpty |
|||
? Image.asset( |
|||
controller.agent.avatarUrl, |
|||
fit: BoxFit.cover, |
|||
errorBuilder: (context, error, stackTrace) { |
|||
return Icon( |
|||
Icons.smart_toy_rounded, |
|||
color: Colors.white, |
|||
size: 22.r, |
|||
); |
|||
}, |
|||
) |
|||
: Icon( |
|||
Icons.smart_toy_rounded, |
|||
color: Colors.white, |
|||
size: 22.r, |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 12.w), |
|||
// AI名称和状态 |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Text( |
|||
'${controller.agent.name}'.tr, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Row( |
|||
children: [ |
|||
Container( |
|||
width: 6.r, |
|||
height: 6.r, |
|||
decoration: const BoxDecoration( |
|||
color: Colors.green, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
SizedBox(width: 4.w), |
|||
Text( |
|||
'online'.tr, // 在线 |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.green, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
actions: [ |
|||
IconButton( |
|||
icon: Icon( |
|||
Icons.phone, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
onPressed: controller.startVoiceRecognition, |
|||
), |
|||
// TTS开关 |
|||
Obx(() => IconButton( |
|||
icon: Icon( |
|||
controller.isTtsEnabled.value |
|||
? Icons.volume_up |
|||
: Icons.volume_off, |
|||
color: controller.isTtsEnabled.value |
|||
? AppColors.primary |
|||
: isDarkMode |
|||
? Colors.grey[400] |
|||
: Colors.grey, |
|||
), |
|||
onPressed: controller.toggleTts, |
|||
)), |
|||
// 更多选项 |
|||
Container( |
|||
width: 34.r, |
|||
height: 34.r, |
|||
margin: EdgeInsets.only(right: 8.w), |
|||
decoration: BoxDecoration( |
|||
color: |
|||
isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100], |
|||
shape: BoxShape.circle, |
|||
boxShadow: [ |
|||
if (!isDarkMode) |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 2, |
|||
spreadRadius: 0.5, |
|||
), |
|||
], |
|||
), |
|||
child: PopupMenuButton( |
|||
icon: Icon(Icons.more_vert, |
|||
color: isDarkMode ? Colors.white : Colors.black87, size: 20.r), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
itemBuilder: (context) => [ |
|||
PopupMenuItem( |
|||
value: 'clear', |
|||
child: Row( |
|||
children: [ |
|||
const Icon(Icons.delete_outline, color: Colors.red), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
'clearChatHistory'.tr, // 清空聊天记录 |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
onTap: () { |
|||
// 延迟执行,避免在弹出菜单关闭前执行 |
|||
Future.delayed( |
|||
const Duration(milliseconds: 100), |
|||
() => controller.clearChat(), |
|||
); |
|||
}, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 构建主体内容 |
|||
Widget _buildBody(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
// 聊天内容区域 |
|||
Expanded( |
|||
child: Obx(() { |
|||
if (controller.messages.isEmpty) { |
|||
return _buildEmptyState(isDarkMode); |
|||
} else { |
|||
return _buildChatList(isDarkMode); |
|||
} |
|||
}), |
|||
), |
|||
|
|||
// 底部输入区域 |
|||
_buildTextInputPanel(isDarkMode), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 构建空状态 |
|||
Widget _buildEmptyState(bool isDarkMode) { |
|||
return Center( |
|||
child: Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Container( |
|||
width: 80.w, |
|||
height: 80.w, |
|||
decoration: BoxDecoration( |
|||
color: |
|||
isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100], |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon( |
|||
Icons.chat_bubble_outline, |
|||
size: 40.w, |
|||
color: |
|||
isDarkMode ? Colors.white.withOpacity(0.5) : Colors.grey[400], |
|||
), |
|||
), |
|||
SizedBox(height: 24.h), |
|||
Text( |
|||
'你好!我是${controller.agent.name}助手', |
|||
style: TextStyle( |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 12.h), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 40.w), |
|||
child: Text( |
|||
'我能帮你回答问题、实时语音翻译、推荐音乐和播客等。有什么我可以帮你的吗?', |
|||
textAlign: TextAlign.center, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
height: 1.5, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建聊天列表 |
|||
Widget _buildChatList(bool isDarkMode) { |
|||
return ListView.builder( |
|||
controller: controller.scrollController, |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|||
itemCount: controller.messages.length, |
|||
reverse: false, |
|||
physics: const AlwaysScrollableScrollPhysics(), |
|||
cacheExtent: 1000, // 增加缓存范围,减少重建 |
|||
itemBuilder: (context, index) { |
|||
final message = controller.messages[index]; |
|||
return _buildMessageItem(message, isDarkMode); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
// 构建消息项 |
|||
Widget _buildMessageItem(Message message, bool isDarkMode) { |
|||
final isUser = message.role == 'user'; |
|||
|
|||
return Padding( |
|||
padding: EdgeInsets.only(bottom: 16.h), |
|||
child: Row( |
|||
mainAxisAlignment: |
|||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
if (!isUser) _buildAvatar(false, isDarkMode), |
|||
SizedBox(width: 8.w), |
|||
Flexible( |
|||
child: Column( |
|||
crossAxisAlignment: |
|||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, |
|||
children: [ |
|||
// 消息气泡 |
|||
Container( |
|||
padding: EdgeInsets.all(12.r), |
|||
decoration: BoxDecoration( |
|||
color: isUser |
|||
? AppColors.primary |
|||
: isDarkMode |
|||
? AppColors.cardBackground |
|||
: Colors.grey[200], |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
// 消息内容 |
|||
_buildMessageContent(message, isDarkMode, isUser), |
|||
|
|||
// 加载中动画 |
|||
if (message.isIntermediate) |
|||
Padding( |
|||
padding: EdgeInsets.only(top: 8.h), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: List.generate( |
|||
3, |
|||
(index) => _buildLoadingDot(index, isDarkMode), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
|
|||
// 时间戳 |
|||
Padding( |
|||
padding: EdgeInsets.only(top: 4.h, left: 4.w, right: 4.w), |
|||
child: Text( |
|||
_formatTime(message.timestamp), |
|||
style: TextStyle( |
|||
fontSize: 10.sp, |
|||
color: isDarkMode ? Colors.grey[500] : Colors.grey[500], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
if (isUser) _buildAvatar(true, isDarkMode), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildMessageContent(Message message, bool isDarkMode, bool isUser) { |
|||
Widget box = const SizedBox(); |
|||
if (message.content.isNotEmpty) { |
|||
box = SelectableText( |
|||
message.content.tr, |
|||
style: TextStyle( |
|||
color: isUser |
|||
? Colors.white |
|||
: isDarkMode |
|||
? Colors.white |
|||
: Colors.black, |
|||
fontSize: 16.sp, |
|||
height: 1.5, |
|||
), |
|||
); |
|||
} |
|||
if (message.meta != null) { |
|||
if (message.meta?['card_weather'] != null) { |
|||
box = WeatherDayCard(message.meta?['card_weather'], isDarkMode); |
|||
} |
|||
if (message.meta?['card_music'] != null) { |
|||
box = ChatMusicCard(0, message.meta?['card_music'], isDarkMode); |
|||
} |
|||
if (message.meta?['card_spotify'] != null) { |
|||
box = ChatMusicCard(1, message.meta?['card_spotify'], isDarkMode); |
|||
} |
|||
if (message.meta?['card_navigation'] != null) { |
|||
box = ChatNavigationCard( |
|||
message.meta?['card_navigation'], message.content, isDarkMode); |
|||
} |
|||
} |
|||
return box; |
|||
} |
|||
|
|||
// 构建文本输入面板 |
|||
Widget _buildTextInputPanel(bool isDarkMode) { |
|||
final textController = |
|||
TextEditingController(text: controller.inputText.value); |
|||
|
|||
return Container( |
|||
height: 48.w, |
|||
margin: EdgeInsets.only(left: 16.w, right: 16.w, top: 10.w, bottom: 20.w), |
|||
clipBehavior: Clip.hardEdge, |
|||
decoration: BoxDecoration( |
|||
color: controller.isHoldSpeak.value |
|||
? AppColors.primary |
|||
: isDarkMode |
|||
? AppColors.cardBackground |
|||
: Colors.white, |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 3, |
|||
offset: const Offset(0, -1), |
|||
), |
|||
], |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
if (!controller.isHoldSpeak.value) 6.horizontalSpace, |
|||
if (!controller.isHoldSpeak.value) |
|||
Padding( |
|||
padding: EdgeInsets.all(6.w), |
|||
child: Icon( |
|||
Icons.camera_alt_outlined, |
|||
size: 28.w, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: Obx( |
|||
() => controller.isTextInput.value |
|||
? Center( |
|||
child: TextField( |
|||
controller: textController, |
|||
style: TextStyle( |
|||
fontSize: 15.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
textInputAction: TextInputAction.send, |
|||
decoration: InputDecoration( |
|||
hintText: 'inputMessage'.tr, // 输入消息... |
|||
hintStyle: TextStyle(color: Colors.grey[500]), |
|||
filled: false, |
|||
border: InputBorder.none, |
|||
focusedBorder: InputBorder.none, |
|||
contentPadding: EdgeInsets.only(left: 6.w), |
|||
), |
|||
onSubmitted: (value) { |
|||
if (value.trim().isNotEmpty) { |
|||
controller.sendMessage(value); |
|||
textController.clear(); |
|||
controller.inputText.value = ''; // 清空输入框 |
|||
} |
|||
}, |
|||
), |
|||
) |
|||
: Listener( |
|||
onPointerDown: (event) { |
|||
controller.talkLongPress(); |
|||
}, |
|||
onPointerUp: (event) { |
|||
controller.talkLongPressUp(); |
|||
}, |
|||
child: Container( |
|||
padding: EdgeInsets.only( |
|||
left: controller.isHoldSpeak.value ? 0 : 40.w), |
|||
color: Colors.transparent, |
|||
child: controller.isHoldSpeak.value |
|||
? Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: List.generate(40, (int index) { |
|||
return Container( |
|||
width: 3.w, |
|||
height: 10.w, |
|||
margin: |
|||
EdgeInsets.symmetric(horizontal: 2.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(3.r), |
|||
), |
|||
); |
|||
}), |
|||
) |
|||
: Center( |
|||
child: Text( |
|||
'按住说话', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode |
|||
? Colors.white |
|||
: Colors.black87, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
if (!controller.isHoldSpeak.value) |
|||
GestureDetector( |
|||
onTap: () { |
|||
controller.isTextInput.value = !controller.isTextInput.value; |
|||
}, |
|||
child: Container( |
|||
width: 28.w, |
|||
height: 28.w, |
|||
margin: EdgeInsets.all(6.w), |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(28.r), |
|||
border: Border.all( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
width: 2, |
|||
), |
|||
), |
|||
child: Obx( |
|||
() => Icon( |
|||
controller.isTextInput.value ? Icons.mic : Icons.keyboard, |
|||
size: 20.w, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
if (!controller.isHoldSpeak.value) |
|||
Container( |
|||
width: 28.w, |
|||
height: 28.w, |
|||
margin: EdgeInsets.all(6.w), |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(28.r), |
|||
border: Border.all( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
width: 2, |
|||
), |
|||
), |
|||
child: Icon( |
|||
Icons.add, |
|||
size: 20.w, |
|||
), |
|||
), |
|||
if (!controller.isHoldSpeak.value) 6.horizontalSpace, |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建语音输入面板 |
|||
Widget _buildVoiceInputPanel(bool isDarkMode) { |
|||
return AnimatedGradientBackground( |
|||
SafeArea( |
|||
child: Column( |
|||
children: [ |
|||
Row( |
|||
children: [], |
|||
), |
|||
Expanded( |
|||
child: Center( |
|||
child: Container( |
|||
width: 200.w, |
|||
height: 200.w, |
|||
clipBehavior: Clip.hardEdge, |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
colors: [ |
|||
Colors.blue.withOpacity(0.4), |
|||
Colors.purple.withOpacity(0.4) |
|||
], |
|||
begin: Alignment.centerLeft, |
|||
end: Alignment.centerRight, |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: CircularFlowAnimation(), |
|||
), |
|||
), |
|||
), |
|||
SizedBox( |
|||
height: 80.h, |
|||
child: Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 20.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
crossAxisAlignment: CrossAxisAlignment.center, |
|||
children: [ |
|||
// 语音录制按钮 |
|||
Container( |
|||
width: 60.r, |
|||
height: 60.r, |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? Colors.white.withOpacity(0.1) |
|||
: Colors.white, |
|||
shape: BoxShape.circle, |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.1), |
|||
blurRadius: 8, |
|||
spreadRadius: 1, |
|||
), |
|||
], |
|||
), |
|||
child: Center( |
|||
child: Obx(() => Icon( |
|||
controller.isListening.value |
|||
? Icons.mic |
|||
: Icons.mic_none, |
|||
color: controller.isListening.value |
|||
? AppColors.primary |
|||
: Colors.grey.shade400, |
|||
size: 28.r, |
|||
)), |
|||
), |
|||
), |
|||
|
|||
// 中间音频波形和状态文本 |
|||
Expanded( |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
// 音频波形区域 |
|||
Container( |
|||
height: 50.h, |
|||
child: Obx(() { |
|||
final isSpeaking = |
|||
controller.isSpeechDetected.value; |
|||
return Center( |
|||
child: Container( |
|||
width: 220.w, |
|||
height: 30.h, |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: List.generate( |
|||
23, |
|||
(index) { |
|||
// 随机高度模拟波形 |
|||
double height = isSpeaking |
|||
? (Random().nextDouble() * 20.0 + |
|||
5.0) |
|||
.h |
|||
: 2.h; |
|||
|
|||
return SizedBox( |
|||
width: 6.w, |
|||
child: Center( |
|||
child: AnimatedContainer( |
|||
duration: const Duration( |
|||
milliseconds: 500), |
|||
width: 2.5.w, |
|||
height: height, |
|||
margin: EdgeInsets.symmetric( |
|||
horizontal: 1.5.w), |
|||
decoration: BoxDecoration( |
|||
color: isSpeaking |
|||
? AppColors.primary |
|||
: isDarkMode |
|||
? Colors.grey.shade600 |
|||
: Colors.grey.shade400, |
|||
borderRadius: |
|||
BorderRadius.circular(2.r), |
|||
), |
|||
), |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
), |
|||
); |
|||
}), |
|||
), |
|||
|
|||
// 语音状态文本 |
|||
Obx(() => Text( |
|||
controller.isSpeechDetected.value |
|||
? 'imListening'.tr // 正在聆听... |
|||
: 'canStartTalking'.tr, // 你可以开始说话 |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode |
|||
? Colors.grey[400] |
|||
: Colors.grey[700], |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
|
|||
// 关闭按钮 |
|||
Container( |
|||
width: 60.r, |
|||
height: 60.r, |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? Colors.white.withOpacity(0.1) |
|||
: Colors.white, |
|||
shape: BoxShape.circle, |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.1), |
|||
blurRadius: 8, |
|||
spreadRadius: 1, |
|||
), |
|||
], |
|||
), |
|||
child: Center( |
|||
child: IconButton( |
|||
icon: Icon( |
|||
Icons.close, |
|||
color: isDarkMode |
|||
? Colors.white |
|||
: Colors.grey.shade600, |
|||
size: 28.r, |
|||
), |
|||
padding: EdgeInsets.zero, |
|||
onPressed: controller.stopVoiceRecognition, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建头像 |
|||
Widget _buildAvatar(bool isUser, bool isDarkMode) { |
|||
return Container( |
|||
width: 36.r, |
|||
height: 36.r, |
|||
decoration: BoxDecoration( |
|||
color: isUser |
|||
? (isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[200]) |
|||
: AppColors.primary, |
|||
shape: BoxShape.circle, |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 2, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: isUser |
|||
? Center( |
|||
child: Icon( |
|||
Icons.person, |
|||
color: isDarkMode ? Colors.white : Colors.grey[700], |
|||
size: 20.r, |
|||
), |
|||
) |
|||
: ClipRRect( |
|||
borderRadius: BorderRadius.circular(18.r), |
|||
child: controller.agent.avatarUrl.isNotEmpty |
|||
? Image.asset( |
|||
controller.agent.avatarUrl, |
|||
fit: BoxFit.cover, |
|||
errorBuilder: (context, error, stackTrace) { |
|||
return Icon( |
|||
Icons.smart_toy_rounded, |
|||
color: Colors.white, |
|||
size: 20.r, |
|||
); |
|||
}, |
|||
) |
|||
: Icon( |
|||
Icons.smart_toy_rounded, |
|||
color: Colors.white, |
|||
size: 20.r, |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建加载动画点 |
|||
Widget _buildLoadingDot(int index, bool isDarkMode) { |
|||
return AnimatedBuilder( |
|||
animation: const AlwaysStoppedAnimation(0), |
|||
builder: (context, child) { |
|||
return Container( |
|||
margin: EdgeInsets.symmetric(horizontal: 2.w), |
|||
width: 8.r, |
|||
height: 8.r, |
|||
decoration: BoxDecoration( |
|||
color: |
|||
isDarkMode ? Colors.white.withOpacity(0.5) : Colors.grey[400], |
|||
shape: BoxShape.circle, |
|||
), |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
// 格式化时间 |
|||
String _formatTime(DateTime time) { |
|||
final now = DateTime.now(); |
|||
final today = DateTime(now.year, now.month, now.day); |
|||
//final yesterday = today.subtract(const Duration(days: 1)); |
|||
final messageDate = DateTime(time.year, time.month, time.day); |
|||
|
|||
String timeStr = |
|||
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; |
|||
|
|||
if (messageDate == today) { |
|||
return timeStr; // 只显示时间,如 "14:30" |
|||
} |
|||
// else if (messageDate == yesterday) { |
|||
// return 'Yesterday $timeStr'; // 如 "Yesterday 14:30" |
|||
// } |
|||
else { |
|||
// 国际通用日期格式 "MM-dd HH:mm" |
|||
return '${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} $timeStr'; |
|||
} |
|||
} |
|||
} |
|||
@ -1,144 +0,0 @@ |
|||
import 'dart:math'; |
|||
|
|||
import 'package:flutter/material.dart'; |
|||
|
|||
class CircularFlowAnimation extends StatefulWidget { |
|||
const CircularFlowAnimation({super.key}); |
|||
|
|||
@override |
|||
State<CircularFlowAnimation> createState() => _CircularFlowAnimationState(); |
|||
} |
|||
|
|||
class _CircularFlowAnimationState extends State<CircularFlowAnimation> |
|||
with SingleTickerProviderStateMixin { |
|||
late AnimationController _controller; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_controller = AnimationController( |
|||
vsync: this, |
|||
duration: const Duration(seconds: 20), |
|||
)..repeat(); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_controller.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return CustomPaint( |
|||
painter: CircularFlowPainter(_controller), |
|||
size: Size.infinite, |
|||
); |
|||
} |
|||
} |
|||
|
|||
class CircularFlowPainter extends CustomPainter { |
|||
final Animation<double> animation; |
|||
|
|||
CircularFlowPainter(this.animation) : super(repaint: animation); |
|||
|
|||
@override |
|||
void paint(Canvas canvas, Size size) { |
|||
final center = Offset(size.width / 2, size.height / 2); |
|||
const baseRadius = 150.0; |
|||
final time = animation.value * 2 * pi; |
|||
|
|||
// 绘制圆形路径 |
|||
_drawCircularPath(canvas, center, baseRadius); |
|||
|
|||
// 绘制三个围绕圆形运动的渐变云朵 |
|||
_drawOrbitingCloud( |
|||
canvas: canvas, |
|||
center: center, |
|||
time: time, |
|||
colors: [Colors.blue, Colors.purple], |
|||
orbitRadius: baseRadius, |
|||
speed: 0.5, |
|||
direction: 1, |
|||
); |
|||
|
|||
_drawOrbitingCloud( |
|||
canvas: canvas, |
|||
center: center, |
|||
time: time, |
|||
colors: [Colors.pink, Colors.orange], |
|||
orbitRadius: baseRadius * 0.8, |
|||
speed: 1.0, |
|||
direction: -1, |
|||
); |
|||
|
|||
_drawOrbitingCloud( |
|||
canvas: canvas, |
|||
center: center, |
|||
time: time, |
|||
colors: [Colors.green, Colors.yellow], |
|||
orbitRadius: baseRadius * 1.2, |
|||
speed: 0.3, |
|||
direction: 1, |
|||
); |
|||
|
|||
_drawOrbitingCloud( |
|||
canvas: canvas, |
|||
center: center, |
|||
time: time, |
|||
colors: [Colors.red, Colors.deepOrangeAccent], |
|||
orbitRadius: baseRadius * 0.5, |
|||
speed: 0.8, |
|||
direction: -1, |
|||
); |
|||
} |
|||
|
|||
void _drawCircularPath(Canvas canvas, Offset center, double radius) { |
|||
final paint = Paint() |
|||
..color = Colors.red |
|||
..style = PaintingStyle.stroke |
|||
..strokeWidth = 2 |
|||
..maskFilter = MaskFilter.blur(BlurStyle.normal, 4); |
|||
|
|||
canvas.drawCircle(center, radius, paint); |
|||
} |
|||
|
|||
void _drawOrbitingCloud({ |
|||
required Canvas canvas, |
|||
required Offset center, |
|||
required double time, |
|||
required List<Color> colors, |
|||
required double orbitRadius, |
|||
required double speed, |
|||
required int direction, |
|||
}) { |
|||
final angle = time * speed * direction; |
|||
final position = Offset( |
|||
center.dx + orbitRadius * cos(angle), |
|||
center.dy + orbitRadius * sin(angle), |
|||
); |
|||
|
|||
final gradient = RadialGradient( |
|||
colors: colors, |
|||
stops: const [0.3, 1.0], |
|||
radius: 0.6, |
|||
focal: Alignment( |
|||
0.1 + cos(angle * 2) * 0.3, |
|||
0.1 + sin(angle * 2) * 0.3, |
|||
), |
|||
); |
|||
|
|||
final paint = Paint() |
|||
..shader = gradient.createShader(Rect.fromCircle( |
|||
center: position, |
|||
radius: 100 + 30 * sin(time * 3), |
|||
)) |
|||
..maskFilter = MaskFilter.blur(BlurStyle.normal, 40) |
|||
..blendMode = BlendMode.plus; |
|||
|
|||
canvas.drawCircle(position, 80 + 20 * cos(time * 2), paint); |
|||
} |
|||
|
|||
@override |
|||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true; |
|||
} |
|||
@ -1,92 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
|
|||
class WeatherDayCard extends StatelessWidget { |
|||
final Map? data; |
|||
final bool isDarkMode; |
|||
|
|||
const WeatherDayCard(this.data, this.isDarkMode, {super.key}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
data?['city'], |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 10.sp, |
|||
), |
|||
), |
|||
Text( |
|||
'${data?['temperature']}°', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 30.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.end, |
|||
children: [ |
|||
Icon( |
|||
Icons.sunny, |
|||
color: isDarkMode ? Colors.white : Colors.amber[600]!, |
|||
size: 18.w, |
|||
), |
|||
Text( |
|||
'${data?['weatherCondition']},${data?['windDirection']}风', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'湿\n度', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 10.sp, |
|||
height: 1, |
|||
), |
|||
), |
|||
5.horizontalSpace, |
|||
Text( |
|||
'${data?['humidity']}', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 20.sp, |
|||
), |
|||
), |
|||
10.horizontalSpace, |
|||
Text( |
|||
'风\n力', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 10.sp, |
|||
height: 1, |
|||
), |
|||
), |
|||
5.horizontalSpace, |
|||
Text( |
|||
'${data?['windForce']}', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 20.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
], |
|||
); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue