import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:get/get.dart'; import 'package:audioplayers/audioplayers.dart'; import '../models/message_model.dart'; import '../theme/app_theme.dart'; import '../services/whatsapp_service.dart'; // ─── Full-Screen Image Viewer ───────────────────────────────────────────── class FullScreenImageViewer extends StatelessWidget { final Uint8List bytes; final String heroTag; const FullScreenImageViewer({ super.key, required this.bytes, required this.heroTag, }); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, iconTheme: const IconThemeData(color: Colors.white), elevation: 0, ), body: Center( child: Hero( tag: heroTag, child: InteractiveViewer( minScale: 0.5, maxScale: 5.0, child: Image.memory(bytes, fit: BoxFit.contain), ), ), ), ); } } // ─── Message Bubble ─────────────────────────────────────────────────────── 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: [ // Sender name in group chats if (!isMe && message.author != null) ...[ Text( message.author!, style: const TextStyle( color: AppTheme.primary, fontSize: 12, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 4), ], // Media widget if (message.hasMedia) ...[ InteractiveMediaWidget(message: message), const SizedBox(height: 6), ], // Text + time + ACK 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/Queued return const Icon(Icons.access_time, size: 13, color: AppTheme.textSecondary); case 2: // Sent (single grey tick) return const Icon(Icons.done, size: 15, color: AppTheme.textSecondary); case 3: // Delivered (double grey tick) return const Icon(Icons.done_all, size: 15, color: AppTheme.textSecondary); case 4: // Read (double blue tick) return const Icon(Icons.done_all, size: 15, color: Colors.blue); case 5: // Played audio/video (double blue with wave icon) 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); } } // ─── Interactive Media Widget ───────────────────────────────────────────── 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 player state final AudioPlayer _player = AudioPlayer(); StreamSubscription? _posSub; StreamSubscription? _durSub; StreamSubscription? _stateSub; bool _isPlaying = false; double _audioProgress = 0.0; int _audioDurationSeconds = 1; int _audioCurrentSeconds = 0; @override void initState() { super.initState(); _posSub = _player.onPositionChanged.listen((p) { if (mounted) { setState(() { _audioCurrentSeconds = p.inSeconds; if (_audioDurationSeconds > 0) { _audioProgress = p.inMilliseconds / (_audioDurationSeconds * 1000); if (_audioProgress > 1.0) _audioProgress = 1.0; } }); } }); _durSub = _player.onDurationChanged.listen((d) { if (mounted) { setState(() { _audioDurationSeconds = d.inSeconds > 0 ? d.inSeconds : 1; }); } }); _stateSub = _player.onPlayerStateChanged.listen((s) { if (mounted) { setState(() { _isPlaying = s == PlayerState.playing; if (s == PlayerState.completed) { _audioProgress = 0.0; _audioCurrentSeconds = 0; _isPlaying = false; } }); } }); } @override void dispose() { _posSub?.cancel(); _durSub?.cancel(); _stateSub?.cancel(); _player.dispose(); super.dispose(); } void _toggleAudioPlayback(String base64Data) async { try { if (_isPlaying) { await _player.pause(); } else { final bytes = base64Decode(base64Data); final safeId = widget.message.id.replaceAll(RegExp(r'[^a-zA-Z0-9]'), ''); final tempDir = Directory.systemTemp; final tempFile = File('${tempDir.path}/voice_$safeId.mp3'); if (!await tempFile.exists()) { await tempFile.writeAsBytes(bytes); } await _player.play(DeviceFileSource(tempFile.path)); } } catch (e) { print('[AUDIO PLAYBACK ERROR] $e'); Get.snackbar( 'Playback Error', 'Could not play audio message: $e', snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.redAccent.withOpacity(0.8), colorText: Colors.white, ); } } @override Widget build(BuildContext context) { return Obx(() { final cachedMedia = _svc.mediaCache[widget.message.id]; if (cachedMedia != null) { return _buildDownloadedMedia(context, 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 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(BuildContext context, String base64Data) { final bytes = base64Decode(base64Data); // ── Image / Sticker: full-screen viewer on tap ───────────────────────── if (widget.message.type == "image" || widget.message.type == "sticker") { final heroTag = 'img_${widget.message.id}'; return GestureDetector( onTap: () { Navigator.of(context).push( PageRouteBuilder( opaque: false, barrierColor: Colors.black, pageBuilder: (_, __, ___) => FullScreenImageViewer( bytes: bytes, heroTag: heroTag, ), transitionsBuilder: (_, anim, __, child) => FadeTransition(opacity: anim, child: child), ), ); }, child: Hero( tag: heroTag, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 250), child: Image.memory( bytes, fit: BoxFit.cover, width: double.infinity, ), ), ), ), ); } // ── Audio / Voice Note ───────────────────────────────────────────────── if (widget.message.type == "audio") { final durationStr = _audioDurationSeconds > 1 ? '${(_audioDurationSeconds ~/ 60).toString().padLeft(1, '0')}:${(_audioDurationSeconds % 60).toString().padLeft(2, '0')}' : '0:${_audioCurrentSeconds.toString().padLeft(2, '0')}'; 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_circle_filled : Icons.play_circle_filled, color: AppTheme.primary, size: 36, ), onPressed: () => _toggleAudioPlayback(base64Data), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 2.5, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), overlayShape: const RoundSliderOverlayShape(overlayRadius: 10), activeTrackColor: AppTheme.primary, inactiveTrackColor: AppTheme.surfaceLight, thumbColor: AppTheme.primary, overlayColor: AppTheme.primary.withOpacity(0.2), ), child: Slider( value: _audioProgress.clamp(0.0, 1.0), onChanged: (v) async { final targetMs = (v * _audioDurationSeconds * 1000).toInt(); await _player.seek(Duration(milliseconds: targetMs)); }, ), ), Padding( padding: const EdgeInsets.only(left: 4), child: Text( durationStr, style: const TextStyle( color: AppTheme.textSecondary, fontFamily: 'monospace', fontSize: 10, ), ), ), ], ), ), ], ), ); } // ── Default: Document / File ─────────────────────────────────────────── 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"; } } }