import 'dart:convert'; import 'dart:io'; 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:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:intl/intl.dart'; import '../models/message.dart'; import '../theme/app_theme.dart'; import 'image_viewer.dart'; import 'pdf_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) { final uri = Uri.tryParse(href); if (uri != null) { launchUrl(uri, mode: LaunchMode.externalApplication); } } }, ); } 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), ], ], ); } /// True if the mimeType is a renderable image format. bool get _isImageMime { final mime = message.mimeType?.toLowerCase() ?? 'image/jpeg'; return mime.startsWith('image/'); } Widget _buildImageContent(BuildContext context) { if (message.imageBase64 == null || message.imageBase64!.isEmpty) { return const Text('Image unavailable'); } // Non-image files (PDF, CSV, etc.) — show file card instead of Image.memory if (!_isImageMime) { return _buildFileCard(context); } // 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 _buildFileCard(BuildContext context) { final mime = message.mimeType ?? 'application/octet-stream'; final caption = message.content.isNotEmpty ? message.content : 'File'; final isPdf = mime == 'application/pdf'; // File type icon IconData icon; Color iconColor; if (isPdf) { icon = Icons.picture_as_pdf; iconColor = Colors.red; } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') { icon = Icons.table_chart; iconColor = Colors.green; } else if (mime.contains('word') || mime.contains('document')) { icon = Icons.description; iconColor = Colors.blue; } else if (mime == 'text/plain' || mime == 'application/json') { icon = Icons.text_snippet; iconColor = Colors.grey; } else { icon = Icons.insert_drive_file; iconColor = Colors.blueGrey; } final sizeKB = ((message.imageBase64?.length ?? 0) * 3 / 4 / 1024).round(); return GestureDetector( onTap: () => _openFile(context), child: Container( width: 260, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25), borderRadius: BorderRadius.circular(8), border: Border.all( color: (_isUser ? Colors.white : Colors.grey).withAlpha(50), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 32, color: iconColor), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( caption, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: _isUser ? Colors.white : null, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB', style: TextStyle( fontSize: 11, color: (_isUser ? Colors.white : Colors.grey).withAlpha(180), ), ), ], ), ), Icon( Icons.open_in_new, size: 20, color: (_isUser ? Colors.white : Colors.grey).withAlpha(150), ), ], ), ], ), ), ); } Future _openFile(BuildContext context) async { final data = message.imageBase64; if (data == null || data.isEmpty) return; try { final bytes = Uint8List.fromList( base64Decode(data.contains(',') ? data.split(',').last : data), ); final mime = message.mimeType ?? 'application/octet-stream'; // PDFs: open inline viewer if (mime == 'application/pdf') { if (context.mounted) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => PdfViewerScreen( pdfBytes: bytes, title: message.content.isNotEmpty ? message.content : 'PDF', ), ), ); } return; } // Other files: save to temp and share final ext = _mimeToExt(mime); final dir = await getTemporaryDirectory(); final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext'; final file = File('${dir.path}/$fileName'); await file.writeAsBytes(bytes); await SharePlus.instance.share( ShareParams(files: [XFile(file.path, mimeType: mime)]), ); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Could not open file: $e')), ); } } } String _mimeToExt(String mime) { const map = { 'application/pdf': 'pdf', 'application/msword': 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 'application/vnd.ms-excel': 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'text/plain': 'txt', 'text/csv': 'csv', 'application/json': 'json', }; return map[mime] ?? 'bin'; } Widget _buildFooter(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final dt = DateTime.fromMillisecondsSinceEpoch(message.timestamp); final time = DateFormat('dd.MM. HH:mm').format(dt); 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')}'; } }