import 'package:voitrans/core/utils/logger.dart'; import 'package:voitrans/modules/agent/views/google_navigation_card.dart'; import 'package:voitrans/modules/agent/views/music_card.dart'; import 'package:voitrans/modules/agent/views/qqmusic_card.dart'; import 'package:voitrans/modules/agent/views/weather_baidu.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: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 { final bool isUser; final String message; final DateTime timestamp; final bool isRecognizing; final bool hasImage; final String? imagePath; final Map? meta; const MessageBubble({ Key? key, required this.isUser, required this.message, required this.timestamp, this.isRecognizing = false, this.hasImage = false, this.imagePath, this.meta, }) : 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: [ 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: _buildMessageContent(context), ), const SizedBox(height: 4), // 识别中的消息不显示时间 if (!isRecognizing) Row( mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.spaceBetween, children: [ // 时间戳(左对齐或右对齐) Text( _formatTimestamp(timestamp), style: TextStyle( fontSize: 10, 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( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( message, style: const TextStyle( color: Colors.white, fontStyle: FontStyle.italic, fontSize: 14, height: 1.4, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), _buildTypingIndicator(), ], ); } if (!isUser && message.isEmpty) { return _buildTypingIndicator(); } // 如果是图片消息,显示图片 if (hasImage && imagePath != null) { // 如果消息是默认的[图片]标记,只显示图片 if (message == '[图片]') { return Container( padding: const EdgeInsets.all(8), child: GestureDetector( onTap: () { // 点击图片时打开图片查看器 Get.find() .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() .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; final data = music_search_songs['data']; if (data != null && data is List) { final rawList = data as List? ?? []; final playlist = rawList.map((e) => Map.from(e)).toList(); //音乐播放列表 return buildMusicContent(context, playlist); } } else if (meta!.keys.contains('qqmusic_searchandplay_songs')) { final qqmusic_search_songs = meta!['qqmusic_searchandplay_songs'] as Map; 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() .toList(); //音乐播放列表 return buildQQMusicContent(context, songList); } } else if (meta!.keys.contains('maps_weather')) { //高德的 final maps_weather = meta!['maps_weather'] as Map; final city = maps_weather['city']; if (city != null) { final rawList = maps_weather['forecasts'] as List? ?? []; final daylist = rawList.map((e) => Map.from(e)).toList(); final now = daylist.isNotEmpty ? daylist[0] : {}; //天气 return buildWeatherContent( context, city, now, daylist, ); } } else if (meta!.keys.contains('map_weather')) { //百度的 final map_weather = meta!['map_weather'] as Map; final status = map_weather['status']; if (status == 0) { final result = map_weather['result'] as Map; final city = result["location"]["city"] as String? ?? ''; final rawList = result['forecasts'] as List? ?? []; final daylist = rawList.map((e) => Map.from(e)).toList(); final now = daylist.isNotEmpty ? daylist[0] : {}; //天气 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.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) { final card_navigation = maps_route_navigation["card_navigation"] as Map; final start = card_navigation['start'] as String? ?? ''; final end = card_navigation['end'] 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.from(e)).toList(); final rawNavigation = card_navigation['navigation']; final Map? navigation = rawNavigation is Map ? rawNavigation : null; // print("导航实时数据 ${rawNavigation}"); // 导航 return navigationCard(context, start: start, end: end, 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) { 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.from(e)).toList(); final rawNavigation = data['navigation']; final Map? navigation = rawNavigation is Map ? 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); // } } // 使用Markdown渲染AI消息,用户消息仍然使用普通文本 if (!isUser) { return 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, ); } // 用户消息仍然使用普通文本 return 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, ), ); } /// 构建输入中的指示器 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 ''; } } } /// 动画点组件 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 _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), ); _animation = Tween(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, ), ], ), ); } }