import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:intl/intl.dart'; import '../models/message.dart'; import '../theme/app_theme.dart'; import 'image_viewer.dart'; // Cache decoded image bytes to prevent flicker on widget rebuild final Map _imageCache = {}; /// Chat message bubble with support for text, voice, and image types. class MessageBubble extends StatelessWidget { final Message message; final VoidCallback? onPlay; final VoidCallback? onChainPlay; final VoidCallback? onDelete; final bool isPlaying; const MessageBubble({ super.key, required this.message, this.onPlay, this.onChainPlay, this.onDelete, this.isPlaying = false, }); bool get _isUser => message.role == MessageRole.user; bool get _isSystem => message.role == MessageRole.system; @override Widget build(BuildContext context) { if (_isSystem) return _buildSystem(context); final isDark = Theme.of(context).brightness == Brightness.dark; return Align( alignment: _isUser ? Alignment.centerRight : Alignment.centerLeft, child: GestureDetector( onLongPress: () => _showContextMenu(context), child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.78, ), margin: EdgeInsets.only( left: _isUser ? 48 : 16, right: _isUser ? 16 : 48, bottom: 6, ), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: _isUser ? (isDark ? AppColors.userBubble : AppColors.lightUserBubble) : (isDark ? AppColors.assistantBubble : AppColors.lightAssistantBubble), borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(_isUser ? 16 : 4), bottomRight: Radius.circular(_isUser ? 4 : 16), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildContent(context), const SizedBox(height: 4), _buildFooter(context), ], ), ), ), ); } Widget _buildSystem(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Center( child: Container( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: isDark ? AppColors.systemBubble : Colors.grey.shade200, borderRadius: BorderRadius.circular(12), ), child: Text( message.content, style: TextStyle( fontSize: 12, color: isDark ? AppColors.darkTextTertiary : Colors.grey.shade600, fontStyle: FontStyle.italic, ), textAlign: TextAlign.center, ), ), ); } Widget _buildContent(BuildContext context) { switch (message.type) { case MessageType.text: if (_isUser) { return SelectableText( message.content, style: const TextStyle( fontSize: 15, color: Colors.white, height: 1.4, ), ); } return _buildMarkdown(context); case MessageType.voice: return _buildVoiceContent(context); case MessageType.image: return _buildImageContent(context); } } Widget _buildMarkdown(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final textColor = isDark ? Colors.white : Colors.black87; final codeBackground = isDark ? Colors.white.withAlpha(20) : Colors.black.withAlpha(15); return MarkdownBody( data: message.content, selectable: true, softLineBreak: true, styleSheet: MarkdownStyleSheet( p: TextStyle(fontSize: 15, height: 1.4, color: textColor), h1: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor), h2: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor), h3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor), strong: TextStyle(fontWeight: FontWeight.bold, color: textColor), em: TextStyle(fontStyle: FontStyle.italic, color: textColor), code: TextStyle( fontSize: 13, fontFamily: 'monospace', color: textColor, backgroundColor: codeBackground, ), codeblockDecoration: BoxDecoration( color: codeBackground, borderRadius: BorderRadius.circular(8), ), codeblockPadding: const EdgeInsets.all(10), listBullet: TextStyle(fontSize: 15, color: textColor), blockquoteDecoration: BoxDecoration( border: Border( left: BorderSide(color: AppColors.accent, width: 3), ), ), blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4), ), onTapLink: (text, href, title) { if (href != null) { Clipboard.setData(ClipboardData(text: href)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Link copied: $href'), duration: const Duration(seconds: 2), ), ); } }, ); } Widget _buildVoiceContent(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ // Play button GestureDetector( onTap: onPlay, onDoubleTap: onChainPlay, child: Container( width: 36, height: 36, decoration: BoxDecoration( color: _isUser ? Colors.white.withAlpha(50) : AppColors.accent.withAlpha(50), shape: BoxShape.circle, ), child: Icon( isPlaying ? Icons.pause : Icons.play_arrow, color: _isUser ? Colors.white : AppColors.accent, size: 20, ), ), ), const SizedBox(width: 8), // Waveform bars SizedBox( width: 120, height: 24, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate(20, (i) { final height = 4.0 + 16.0 * sin(i * 0.5).abs(); return Container( width: 3, height: height, decoration: BoxDecoration( color: _isUser ? Colors.white.withAlpha(180) : (isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary), borderRadius: BorderRadius.circular(2), ), ); }), ), ), const SizedBox(width: 8), // Duration if (message.duration != null) Text( _formatDuration(message.duration!), style: TextStyle( fontSize: 12, color: _isUser ? Colors.white70 : (isDark ? AppColors.darkTextTertiary : AppColors.lightTextSecondary), ), ), ], ), // Transcript if (message.content.isNotEmpty) ...[ const SizedBox(height: 8), if (_isUser) SelectableText( message.content, style: TextStyle( fontSize: 14, color: Colors.white.withAlpha(220), height: 1.3, ), ) else _buildMarkdown(context), ], ], ); } Widget _buildImageContent(BuildContext context) { if (message.imageBase64 == null || message.imageBase64!.isEmpty) { return const Text('Image unavailable'); } // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries if (!_imageCache.containsKey(message.id)) { if (_imageCache.length >= 50) { _imageCache.remove(_imageCache.keys.first); } final raw = message.imageBase64!; _imageCache[message.id] = Uint8List.fromList(base64Decode( raw.contains(',') ? raw.split(',').last : raw, )); } final bytes = _imageCache[message.id]!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: (_) => ImageViewer(imageBytes: bytes), ), ); }, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory( bytes, width: 260, height: 180, fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, e, st) => const SizedBox( width: 260, height: 60, child: Center(child: Text('Image decode error')), ), ), ), ), if (message.content.isNotEmpty) ...[ const SizedBox(height: 6), Text( message.content, style: TextStyle( fontSize: 14, color: _isUser ? Colors.white.withAlpha(220) : null, height: 1.3, ), ), ], ], ); } Widget _buildFooter(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final time = DateFormat('HH:mm').format( DateTime.fromMillisecondsSinceEpoch(message.timestamp), ); return Row( mainAxisSize: MainAxisSize.min, children: [ Text( time, style: TextStyle( fontSize: 11, color: _isUser ? Colors.white60 : (isDark ? AppColors.darkTextTertiary : Colors.grey.shade500), ), ), if (message.status == MessageStatus.sending) ...[ const SizedBox(width: 4), SizedBox( width: 10, height: 10, child: CircularProgressIndicator( strokeWidth: 1.5, color: _isUser ? Colors.white60 : AppColors.accent, ), ), ], if (message.status == MessageStatus.error) ...[ const SizedBox(width: 4), const Icon(Icons.error_outline, size: 14, color: AppColors.error), ], ], ); } void _showContextMenu(BuildContext context) { showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.copy), title: const Text('Copy'), onTap: () { Clipboard.setData(ClipboardData(text: message.content)); Navigator.pop(ctx); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Copied to clipboard'), duration: Duration(seconds: 1), ), ); }, ), if (onDelete != null) ListTile( leading: const Icon(Icons.delete_outline, color: AppColors.error), title: const Text('Delete', style: TextStyle(color: AppColors.error)), onTap: () { Navigator.pop(ctx); onDelete?.call(); }, ), ], ), ), ); } String _formatDuration(int ms) { final seconds = (ms / 1000).ceil(); final m = seconds ~/ 60; final s = seconds % 60; return '$m:${s.toString().padLeft(2, '0')}'; } }