import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:get/get.dart'; import '../models/message_model.dart'; import '../theme/app_theme.dart'; import '../services/whatsapp_service.dart'; class MessageBubble extends StatelessWidget { final MessageModel message; const MessageBubble({super.key, required this.message}); @override Widget build(BuildContext context) { final isMe = message.fromMe; final align = isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start; final bg = isMe ? AppTheme.outgoingMsg : AppTheme.incomingMsg; final radius = isMe ? const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(0), bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ) : const BorderRadius.only( topLeft: Radius.circular(0), topRight: Radius.circular(12), bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ); return Container( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: Column( crossAxisAlignment: align, children: [ Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.75, ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: bg, borderRadius: radius, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Show sender name in group chats if not from me if (!isMe && message.author != null) ...[ Text( message.author!, style: const TextStyle( color: AppTheme.primary, fontSize: 12, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 4), ], // Interactive Media widget if message has media if (message.hasMedia) ...[ InteractiveMediaWidget(message: message), const SizedBox(height: 6), ], // Message text & time row Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( child: Text( message.body, style: const TextStyle( color: AppTheme.textPrimary, fontSize: 15, ), ), ), const SizedBox(width: 8), Padding( padding: const EdgeInsets.only(top: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatTime(message.timestamp), style: const TextStyle( color: AppTheme.textSecondary, fontSize: 10, ), ), if (isMe) ...[ const SizedBox(width: 4), _buildAckIcon(message.ack), ], ], ), ), ], ), ], ), ), ], ), ); } Widget _buildAckIcon(int ack) { switch (ack) { case 1: // Pending return const Icon(Icons.access_time, size: 13, color: AppTheme.textSecondary); case 2: // Sent return const Icon(Icons.done, size: 15, color: AppTheme.textSecondary); case 3: // Delivered return const Icon(Icons.done_all, size: 15, color: AppTheme.textSecondary); case 4: // Read return const Icon(Icons.done_all, size: 15, color: Colors.blue); default: return const SizedBox.shrink(); } } String _formatTime(int timestamp) { if (timestamp == 0) return ''; final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); return DateFormat('h:mm a').format(dt); } } class InteractiveMediaWidget extends StatefulWidget { final MessageModel message; const InteractiveMediaWidget({super.key, required this.message}); @override State createState() => _InteractiveMediaWidgetState(); } class _InteractiveMediaWidgetState extends State { final WhatsAppService _svc = Get.find(); bool _isLoading = false; // Audio simulation state bool _isPlaying = false; double _audioProgress = 0.0; int _audioDurationSeconds = 12; int _audioCurrentSeconds = 0; Timer? _audioTimer; @override void dispose() { _audioTimer?.cancel(); super.dispose(); } void _toggleAudioPlayback() { if (_isPlaying) { _audioTimer?.cancel(); setState(() { _isPlaying = false; }); } else { setState(() { _isPlaying = true; }); const intervalMs = 100; _audioTimer = Timer.periodic(const Duration(milliseconds: intervalMs), (timer) { if (!mounted) { timer.cancel(); return; } setState(() { _audioProgress += intervalMs / (_audioDurationSeconds * 1000); _audioCurrentSeconds = (_audioProgress * _audioDurationSeconds).floor(); if (_audioProgress >= 1.0) { _audioProgress = 0.0; _audioCurrentSeconds = 0; _isPlaying = false; timer.cancel(); } }); }); } } @override Widget build(BuildContext context) { return Obx(() { final cachedMedia = _svc.mediaCache[widget.message.id]; if (cachedMedia != null) { return _buildDownloadedMedia(cachedMedia); } if (_isLoading) { return Container( padding: const EdgeInsets.all(16), width: 140, alignment: Alignment.center, decoration: BoxDecoration( color: Colors.black.withOpacity(0.15), borderRadius: BorderRadius.circular(8), ), child: const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary), ), ); } // Tap to download media placeholder return GestureDetector( onTap: _downloadMedia, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.black.withOpacity(0.15), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(_getIcon(), color: AppTheme.textSecondary, size: 32), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _getLabel(), style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500, fontSize: 13), ), const SizedBox(height: 2), const Text( 'Tap to download', style: TextStyle(color: AppTheme.textSecondary, fontSize: 10), ), ], ), ], ), ), ); }); } Future _downloadMedia() async { setState(() => _isLoading = true); await _svc.downloadAndCacheMedia(widget.message.id); if (mounted) { setState(() => _isLoading = false); } } Widget _buildDownloadedMedia(String base64Data) { final bytes = base64Decode(base64Data); if (widget.message.type == "image" || widget.message.type == "sticker") { return ClipRRect( borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 250), child: Image.memory( bytes, fit: BoxFit.cover, width: double.infinity, ), ), ); } if (widget.message.type == "audio") { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.black.withOpacity(0.15), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon( _isPlaying ? Icons.pause : Icons.play_arrow, color: AppTheme.primary, size: 24, ), onPressed: _toggleAudioPlayback, ), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: LinearProgressIndicator( value: _audioProgress, backgroundColor: AppTheme.surfaceLight, color: AppTheme.primary, ), ), ), Text( '0:${_audioCurrentSeconds.toString().padLeft(2, '0')}', style: const TextStyle( color: AppTheme.textPrimary, fontFamily: 'monospace', fontSize: 11, ), ), ], ), ); } // Default download complete file placeholder return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.black.withOpacity(0.15), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.check_circle_outline, color: AppTheme.primary, size: 32), const SizedBox(width: 12), Text( _getLabel(), style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500), ), ], ), ); } IconData _getIcon() { switch (widget.message.type) { case "image": return Icons.photo_outlined; case "video": return Icons.videocam_outlined; case "audio": return Icons.audiotrack_outlined; case "sticker": return Icons.emoji_emotions_outlined; default: return Icons.insert_drive_file_outlined; } } String _getLabel() { switch (widget.message.type) { case "image": return "Image Attachment"; case "video": return "Video Attachment"; case "audio": return "Audio / Voice Note"; case "sticker": return "Sticker Attachment"; default: return "File Attachment"; } } }