import 'dart:ui' as ui; import 'package:flutter/material.dart'; class GifMessage extends StatefulWidget { final String url; final Color backgroundColor; final Color fallbackTextColor; final double maxSize; const GifMessage({ super.key, required this.url, required this.backgroundColor, required this.fallbackTextColor, this.maxSize = 200, }); @override State createState() => _GifMessageState(); } class _GifMessageState extends State { ImageStream? _imageStream; ImageStreamListener? _listener; ui.Image? _image; Object? _error; bool _isLoading = true; bool _isPaused = false; @override void initState() { super.initState(); _resolveImage(); } @override void didUpdateWidget(covariant GifMessage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.url != widget.url) { _unsubscribe(); _image = null; _error = null; _isLoading = true; _isPaused = false; _resolveImage(); } } @override void dispose() { _unsubscribe(); super.dispose(); } void _resolveImage() { setState(() { _isLoading = true; _error = null; }); final provider = NetworkImage(widget.url); final stream = provider.resolve(ImageConfiguration.empty); _imageStream = stream; _listener = ImageStreamListener( (imageInfo, _) { if (_isPaused) { return; } setState(() { _image = imageInfo.image; _isLoading = false; }); }, onError: (error, _) { setState(() { _error = error; _isLoading = false; }); }, ); stream.addListener(_listener!); } void _retryLoad() { _unsubscribe(); _image = null; _isPaused = false; _resolveImage(); } void _unsubscribe() { if (_imageStream != null && _listener != null) { _imageStream!.removeListener(_listener!); } } void _togglePause() { if (_error != null) { _retryLoad(); return; } if (_image == null) { if (!_isLoading) { _retryLoad(); } return; } setState(() { _isPaused = !_isPaused; }); if (_listener == null || _imageStream == null) { return; } if (_isPaused) { _imageStream!.removeListener(_listener!); } else { _imageStream!.addListener(_listener!); } } @override Widget build(BuildContext context) { // Calculate display size based on image aspect ratio // Use 4:3 placeholder aspect ratio during loading to minimize layout shifts double displayWidth = widget.maxSize; double displayHeight = widget.maxSize * 0.75; if (_image != null) { final imageWidth = _image!.width.toDouble(); final imageHeight = _image!.height.toDouble(); final aspectRatio = imageWidth / imageHeight; // Fit within maxSize, calculating dimensions from aspect ratio if (aspectRatio >= 1) { // Wider than tall: constrain by width displayWidth = widget.maxSize; displayHeight = displayWidth / aspectRatio; } else { // Taller than wide: constrain by height displayHeight = widget.maxSize; displayWidth = displayHeight * aspectRatio; } } Widget content; if (_error != null) { content = Center( child: Text( "Can't load GIF\nTap to retry", textAlign: TextAlign.center, style: TextStyle(fontSize: 12, color: widget.fallbackTextColor), ), ); } else if (_isLoading && _image == null) { content = const Center( child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ); } else if (_image == null) { content = Center( child: Text( 'Tap to load GIF', textAlign: TextAlign.center, style: TextStyle(fontSize: 12, color: widget.fallbackTextColor), ), ); } else { content = RawImage( image: _image, fit: BoxFit.contain, width: displayWidth, height: displayHeight, ); } return GestureDetector( onTap: _togglePause, child: Container( color: widget.backgroundColor, width: displayWidth, height: displayHeight, child: Stack( fit: StackFit.expand, children: [ content, if (_isPaused && _image != null) Container( color: Colors.black.withValues(alpha: 0.2), child: const Center( child: Icon(Icons.pause, color: Colors.white70, size: 28), ), ), ], ), ), ); } }