import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; 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 isDark = AppTheme.isDark(context); final bg = isMe ? AppTheme.outgoingMsg(context) : AppTheme.incomingMsg(context); final radius = isMe ? const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(4), bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ) : const BorderRadius.only( topLeft: Radius.circular(4), topRight: Radius.circular(12), bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ); // Incoming message shadow/border in light mode final BoxDecoration decoration = BoxDecoration( color: bg, borderRadius: radius, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(isDark ? 0.2 : 0.08), blurRadius: 2, offset: const Offset(0, 1), ), ], ); return Container( margin: EdgeInsets.only( top: 2, bottom: 2, left: isMe ? 60 : 8, right: isMe ? 8 : 60, ), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ // Tail + bubble Stack( children: [ // Message bubble Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.78, ), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: decoration, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Sender name in group chats (incoming only) if (!isMe && message.author != null) ...[ Text( message.author!, style: const TextStyle( color: AppTheme.primaryDark, fontSize: 12.5, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 2), ], // Media if (message.hasMedia) ...[ InteractiveMediaWidget(message: message), const SizedBox(height: 4), ], // Text + time + ACK row _buildTextTimeRow(context, isMe), ], ), ), ], ), ], ), ); } Widget _buildTextTimeRow(BuildContext context, bool isMe) { // If body is empty (media-only message), just show time+ack final hasBody = message.body.trim().isNotEmpty; if (!hasBody && message.hasMedia) { // Show only time+ack at bottom right of media return Align( alignment: Alignment.bottomRight, child: _timeAckRow(context, isMe), ); } return Wrap( alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.end, children: [ if (hasBody) Text( message.body, style: TextStyle( color: AppTheme.textPrimary(context), fontSize: 15, ), ), const SizedBox(width: 4), _timeAckRow(context, isMe), ], ); } Widget _timeAckRow(BuildContext context, bool isMe) { return Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatTime(message.timestamp), style: TextStyle( color: AppTheme.textSecondary(context), fontSize: 11, ), ), if (isMe) ...[ const SizedBox(width: 3), _buildAckIcon(context, message.ack), ], ], ); } // ── ACK icon ────────────────────────────────────────────────────────────── // Real WhatsApp ACK values from whatsapp-web.js: // -1 = error // 0 = pending (clock icon) // 1 = sent (single grey tick ✓) // 2 = delivered/received (double grey tick ✓✓) // 3 = read/played (double BLUE tick ✓✓) Widget _buildAckIcon(BuildContext context, int ack) { switch (ack) { case -1: case 0: // Pending — clock return Icon(Icons.access_time_rounded, size: 14, color: AppTheme.textSecondary(context)); case 1: // Sent — single grey tick return Icon(Icons.check_rounded, size: 16, color: AppTheme.textSecondary(context)); case 2: // Delivered — double grey tick return Icon(Icons.done_all_rounded, size: 16, color: AppTheme.textSecondary(context)); case 3: // Read — double blue tick return const Icon(Icons.done_all_rounded, size: 16, color: AppTheme.blueTick); 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.12), 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(10), decoration: BoxDecoration( color: Colors.black.withOpacity(0.10), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: AppTheme.primary.withOpacity(0.15), shape: BoxShape.circle, ), child: Icon(_getIcon(), color: AppTheme.primary, size: 22), ), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _getLabel(), style: TextStyle( color: AppTheme.textPrimary(context), fontWeight: FontWeight.w500, fontSize: 13), ), const SizedBox(height: 2), Row( children: [ Icon(Icons.download_rounded, size: 12, color: AppTheme.textSecondary(context)), const SizedBox(width: 3), Text( 'Tap to download', style: TextStyle( color: AppTheme.textSecondary(context), fontSize: 11), ), ], ), ], ), ], ), ), ); }); } 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 ──────────────────────────────────────────────────── 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: 260), child: Image.memory( bytes, fit: BoxFit.cover, width: double.infinity, ), ), ), ), ); } // ── Audio / Voice Note ───────────────────────────────────────────────── if (widget.message.type == "audio") { final totalSec = _audioDurationSeconds > 1 ? _audioDurationSeconds : _audioCurrentSeconds; final durationStr = '${(totalSec ~/ 60).toString().padLeft(1, '0')}:${(totalSec % 60).toString().padLeft(2, '0')}'; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ CircleAvatar( radius: 18, backgroundColor: AppTheme.primary, child: IconButton( padding: EdgeInsets.zero, icon: Icon( _isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, color: Colors.white, size: 20, ), onPressed: () => _toggleAudioPlayback(base64Data), ), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), overlayShape: const RoundSliderOverlayShape(overlayRadius: 10), activeTrackColor: AppTheme.primary, inactiveTrackColor: AppTheme.textSecondary(context).withOpacity(0.3), 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: TextStyle( color: AppTheme.textSecondary(context), fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ], ), ), ], ), ); } // ── Default: Document / File ─────────────────────────────────────────── return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.black.withOpacity(0.10), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: AppTheme.primary.withOpacity(0.15), shape: BoxShape.circle, ), child: const Icon(Icons.insert_drive_file_rounded, color: AppTheme.primary, size: 22), ), const SizedBox(width: 10), Text( _getLabel(), style: TextStyle( color: AppTheme.textPrimary(context), fontWeight: FontWeight.w500, fontSize: 13), ), ], ), ); } IconData _getIcon() { switch (widget.message.type) { case "image": return Icons.photo_camera_rounded; case "video": return Icons.videocam_rounded; case "audio": return Icons.mic_rounded; case "sticker": return Icons.emoji_emotions_rounded; default: return Icons.insert_drive_file_rounded; } } String _getLabel() { switch (widget.message.type) { case "image": return "Photo"; case "video": return "Video"; case "audio": return "Voice note"; case "sticker": return "Sticker"; default: return "File"; } } }