diff --git a/whatsapp_app/lib/widgets/message_bubble.dart b/whatsapp_app/lib/widgets/message_bubble.dart index fbdb06f..441e699 100644 --- a/whatsapp_app/lib/widgets/message_bubble.dart +++ b/whatsapp_app/lib/widgets/message_bubble.dart @@ -9,6 +9,41 @@ 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; @@ -51,7 +86,7 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Show sender name in group chats if not from me + // Sender name in group chats if (!isMe && message.author != null) ...[ Text( message.author!, @@ -64,13 +99,13 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 4), ], - // Interactive Media widget if message has media + // Media widget if (message.hasMedia) ...[ InteractiveMediaWidget(message: message), const SizedBox(height: 6), ], - // Message text & time row + // Text + time + ACK row Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, @@ -116,13 +151,15 @@ class MessageBubble extends StatelessWidget { Widget _buildAckIcon(int ack) { switch (ack) { - case 1: // Pending + case 1: // Pending/Queued return const Icon(Icons.access_time, size: 13, color: AppTheme.textSecondary); - case 2: // Sent + case 2: // Sent (single grey tick) return const Icon(Icons.done, size: 15, color: AppTheme.textSecondary); - case 3: // Delivered + case 3: // Delivered (double grey tick) return const Icon(Icons.done_all, size: 15, color: AppTheme.textSecondary); - case 4: // Read + 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(); @@ -136,6 +173,7 @@ class MessageBubble extends StatelessWidget { } } +// ─── Interactive Media Widget ───────────────────────────────────────────── class InteractiveMediaWidget extends StatefulWidget { final MessageModel message; @@ -144,6 +182,7 @@ class InteractiveMediaWidget extends StatefulWidget { @override State createState() => _InteractiveMediaWidgetState(); } + class _InteractiveMediaWidgetState extends State { final WhatsAppService _svc = Get.find(); bool _isLoading = false; @@ -211,15 +250,14 @@ class _InteractiveMediaWidgetState extends State { await _player.pause(); } else { final bytes = base64Decode(base64Data); - // Clean special characters from ID to construct a safe filename 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) { @@ -240,7 +278,7 @@ class _InteractiveMediaWidgetState extends State { final cachedMedia = _svc.mediaCache[widget.message.id]; if (cachedMedia != null) { - return _buildDownloadedMedia(cachedMedia); + return _buildDownloadedMedia(context, cachedMedia); } if (_isLoading) { @@ -260,7 +298,7 @@ class _InteractiveMediaWidgetState extends State { ); } - // Tap to download media placeholder + // Tap to download placeholder return GestureDetector( onTap: _downloadMedia, child: Container( @@ -304,24 +342,50 @@ class _InteractiveMediaWidgetState extends State { } } - Widget _buildDownloadedMedia(String base64Data) { + 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") { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 250), - child: Image.memory( - bytes, - fit: BoxFit.cover, - width: double.infinity, + 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( @@ -333,28 +397,49 @@ class _InteractiveMediaWidgetState extends State { children: [ IconButton( icon: Icon( - _isPlaying ? Icons.pause : Icons.play_arrow, + _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, color: AppTheme.primary, - size: 24, + size: 36, ), onPressed: () => _toggleAudioPlayback(base64Data), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), + const SizedBox(width: 8), 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, + 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, + ), + ), + ), + ], ), ), ], @@ -362,7 +447,7 @@ class _InteractiveMediaWidgetState extends State { ); } - // Default download complete file placeholder + // ── Default: Document / File ─────────────────────────────────────────── return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration(