From 374a9685462809905413f7552d7fd55385aa45f8 Mon Sep 17 00:00:00 2001 From: gitea Date: Sat, 15 Nov 2025 00:55:59 +0100 Subject: [PATCH] improvements in ui 2 --- lib/ui/favourites/favourites_screen.dart | 159 ++++++++++++++++++----- 1 file changed, 128 insertions(+), 31 deletions(-) diff --git a/lib/ui/favourites/favourites_screen.dart b/lib/ui/favourites/favourites_screen.dart index be40137..2bb383c 100644 --- a/lib/ui/favourites/favourites_screen.dart +++ b/lib/ui/favourites/favourites_screen.dart @@ -765,7 +765,10 @@ class _RecipeCard extends StatelessWidget { Widget _buildRecipeImage(String imageUrl) { final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { - return Image.network(imageUrl, fit: BoxFit.cover); + return _FadeInImageWidget( + imageUrl: imageUrl, + isNetwork: true, + ); } // Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original) @@ -799,41 +802,16 @@ class _RecipeCard extends StatelessWidget { ); } - return Image.memory( - snapshot.data!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, + return _FadeInImageWidget( + imageBytes: snapshot.data!, ); }, ); } - return Image.network( - imageUrl, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - errorBuilder: (context, error, stackTrace) { - return Container( - color: Colors.grey.shade200, - child: const Icon(Icons.broken_image, size: 48), - ); - }, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - color: Colors.grey.shade200, - child: Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ), - ); - }, + return _FadeInImageWidget( + imageUrl: imageUrl, + isNetwork: true, ); } @@ -842,6 +820,125 @@ class _RecipeCard extends StatelessWidget { } } +/// Widget that displays an image with a smooth fade-in animation. +class _FadeInImageWidget extends StatefulWidget { + final String? imageUrl; + final Uint8List? imageBytes; + final bool isNetwork; + + const _FadeInImageWidget({ + this.imageUrl, + this.imageBytes, + this.isNetwork = false, + }) : assert(imageUrl != null || imageBytes != null, 'Either imageUrl or imageBytes must be provided'); + + @override + State<_FadeInImageWidget> createState() => _FadeInImageWidgetState(); +} + +class _FadeInImageWidgetState extends State<_FadeInImageWidget> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + bool _imageLoaded = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + // Start with opacity 0 + _controller.value = 0.0; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onImageLoaded() { + if (!_imageLoaded && mounted) { + _imageLoaded = true; + _controller.forward(); + } + } + + @override + Widget build(BuildContext context) { + Widget imageWidget; + + if (widget.imageBytes != null) { + // For memory images, use frameBuilder to detect when image is ready + imageWidget = Image.memory( + widget.imageBytes!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if ((wasSynchronouslyLoaded || frame != null) && !_imageLoaded) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onImageLoaded()); + } + return child; + }, + ); + } else if (widget.imageUrl != null) { + // For network images, use frameBuilder to detect when image is ready + imageWidget = Image.network( + widget.imageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 48), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + // Image is loaded, return child and let frameBuilder handle fade-in + return child; + } + return Container( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if ((wasSynchronouslyLoaded || frame != null) && !_imageLoaded) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onImageLoaded()); + } + return child; + }, + ); + } else { + return Container( + color: Colors.grey.shade200, + child: const Icon(Icons.broken_image, size: 48), + ); + } + + return FadeTransition( + opacity: _fadeAnimation, + child: imageWidget, + ); + } +} + /// Widget that displays a video thumbnail preview with a few seconds of playback. class _VideoThumbnailPreview extends StatefulWidget { final String videoUrl;